diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 062e5736c..fb67a3424 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -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
diff --git a/AGENTS.md b/AGENTS.md
index 176232a06..ca40cb52a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fb6fe86ad..7a9093af3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,27 +18,47 @@
- Auto-reply: removed `autoReply` from Discord/Slack/Telegram channel configs; use `requireMention` instead (Telegram topics now support `requireMention` overrides).
### Fixes
+- Discord/Telegram: add per-request retry policy with configurable delays and docs.
+- Telegram: run long polling via grammY runner with per-chat sequentialization and concurrency tied to `agent.maxConcurrent`. Thanks @mukhtharcm for PR #366.
+- macOS: prevent gateway launchd startup race where the app could kill a just-started gateway; avoid unnecessary `bootout` and ensure the job is enabled at login. Fixes #306. Thanks @gupsammy for PR #387.
+- macOS: ignore ciao announcement cancellation rejections during Bonjour shutdown to avoid unhandled exits. Thanks @emanuelst for PR #419.
- Pairing: generate DM pairing codes with CSPRNG, expire pending codes after 1 hour, and avoid re-sending codes for already pending requests.
- Pairing: lock + atomically write pairing stores with 0600 perms and stop logging pairing codes in provider logs.
+- WhatsApp: add self-phone mode (no pairing replies for outbound DMs) and onboarding prompt for personal vs separate numbers (auto allowlist + response prefix for personal).
- Discord: include all inbound attachments in `MediaPaths`/`MediaUrls` (back-compat `MediaPath`/`MediaUrl` still first).
- Sandbox: add `agent.sandbox.workspaceAccess` (`none`/`ro`/`rw`) to control agent workspace visibility inside the container; `ro` hard-disables `write`/`edit`.
- Routing: allow per-agent sandbox overrides (including `workspaceAccess` and `sandbox.tools`) plus per-agent tool policies in multi-agent configs. Thanks @pasogott for PR #380.
+- Tools: make per-agent tool policies override global defaults and run bash synchronously when `process` is disallowed.
+- Tools: scope `process` sessions per agent to prevent cross-agent visibility.
+- Cron: clamp timer delay to avoid TimeoutOverflowWarning. Thanks @emanuelst for PR #412.
+- Web UI: allow reconnect + password URL auth for the control UI and always scrub auth params from the URL. Thanks @oswalpalash for PR #414.
+- ClawdbotKit: fix SwiftPM resource bundling path for `tool-display.json`. Thanks @fcatuhe for PR #398.
- Tools: add Telegram/WhatsApp reaction tools (with per-provider gating). Thanks @zats for PR #353.
+- Tools: flatten literal-union schemas for Claude on Vertex AI. Thanks @carlulsoe for PR #409.
+- Tools: keep tool failure logs concise (no stack traces); full stack only in debug logs.
- Tools: unify reaction removal semantics across Discord/Slack/Telegram/WhatsApp and allow WhatsApp reaction routing across accounts.
+- Android: fix APK output filename renaming after AGP updates. Thanks @Syhids for PR #410.
+- Android: rotate camera photos by EXIF orientation. Thanks @fcatuhe for PR #403.
- Gateway/CLI: add daemon runtime selection (Node recommended; Bun optional) and document WhatsApp/Baileys Bun WebSocket instability on reconnect.
- CLI: add `clawdbot docs` live docs search with pretty output.
- CLI: add `clawdbot agents` (list/add/delete) with wizarded workspace/setup, provider login, and full prune on delete.
+- Discord/Slack: fork thread sessions (agent-scoped) and inject thread starters for context. Thanks @thewilloftheshadow for PR #400.
- Agent: treat compaction retry AbortError as a fallback trigger without swallowing non-abort errors. Thanks @erikpr1994 for PR #341.
+- Agent: add opt-in session pruning for tool results to reduce context bloat. Thanks @maxsumrall for PR #381.
+- Agent: protect bootstrap prefix from context pruning. Thanks @maxsumrall for PR #381.
- Agent: deliver final replies for non-streaming models when block chunking is enabled. Thank you @mneves75 for PR #369!
- Agent: trim bootstrap context injections and keep group guidance concise (emoji reactions allowed). Thanks @tobiasbischoff for PR #370.
+- Agent: return a friendly context overflow response (413/request_too_large). Thanks @alejandroOPI for PR #395.
- Sub-agents: allow `sessions_spawn` model overrides and error on invalid models. Thanks @azade-c for PR #298.
- Sub-agents: skip invalid model overrides with a warning and keep the run alive; tool exceptions now return tool errors instead of crashing the agent.
+- Sessions: forward explicit sessionKey through gateway/chat/node bridge to avoid sub-agent sessionId mixups.
- Heartbeat: default interval 30m; clarified default prompt usage and HEARTBEAT.md template behavior.
- Onboarding: write auth profiles to the multi-agent path (`~/.clawdbot/agents/main/agent/`) so the gateway finds credentials on first startup. Thanks @minghinmatthewlam for PR #327.
- Docs: add missing `ui:install` setup step in the README. Thanks @hugobarauna for PR #300.
- Docs: sanitize AGENTS guidance and add Clawdis migration troubleshooting note. Thanks @buddyh for PR #348.
- Docs: add ClawdHub guide and hubs link for browsing, install, and sync workflows.
- Docs: add FAQ for PNPM/Bun lockfile migration warning; link AgentSkills spec + ClawdHub guide (`/clawdhub`) from skills docs.
+- Docs: add showcase projects (xuezh, gohome, roborock, padel-cli). Thanks @joshp123.
- Build: import tool-display JSON as a module instead of runtime file reads. Thanks @mukhtharcm for PR #312.
- Status: add provider usage snapshots to `/status`, `clawdbot status --usage`, and the macOS menu bar.
- Build: fix macOS packaging QR smoke test for the bun-compiled relay. Thanks @dbhurley for PR #358.
@@ -49,6 +69,8 @@
- Telegram: include sender identity in group envelope headers. (#336)
- Telegram: support forum topics with topic-isolated sessions and message_thread_id routing. Thanks @HazAT, @nachoiacovino, @RandyVentures for PR #321/#333/#334.
- Telegram: add draft streaming via `sendMessageDraft` with `telegram.streamMode`, plus `/reasoning stream` for draft-only reasoning.
+- Telegram: honor `/activation` session mode for group mention gating and clarify group activation docs. Thanks @julianengel for PR #377.
+- Telegram: isolate forum topic transcripts per thread and validate Gemini turn ordering in multi-topic sessions. Thanks @hsrvc for PR #407.
- iMessage: ignore disconnect errors during shutdown (avoid unhandled promise rejections). Thanks @antons for PR #359.
- Messages: stop defaulting ack reactions to 👀 when identity emoji is missing.
- Auto-reply: require slash for control commands to avoid false triggers in normal text.
@@ -58,6 +80,7 @@
- Auto-reply: add per-channel/topic skill filters + system prompts for Discord/Slack/Telegram. Thanks @kitze for PR #286.
- Auto-reply: refresh `/status` output with build info, compact context, and queue depth.
- Commands: add `/stop` to the registry and route native aborts to the active chat session. Thanks @nachoiacovino for PR #295.
+- Commands: allow `/` shorthand for `/model` using `agent.models.*.alias`, without shadowing built-ins. Thanks @azade-c for PR #393.
- Commands: unify native + text chat commands behind `commands.*` config (Discord/Slack/Telegram). Thanks @thewilloftheshadow for PR #275.
- Auto-reply: treat steer during compaction as a follow-up, queued until compaction completes.
- Auth: lock auth profile refreshes to avoid multi-instance OAuth logouts; keep credentials on refresh failure.
@@ -70,6 +93,7 @@
- Doctor: suggest adding the workspace memory system when missing (opt-out via `--no-workspace-suggestions`).
- Doctor: normalize default workspace path to `~/clawd` (avoid `~/clawdbot`).
- Doctor: add `--yes` and `--non-interactive` for headless/automation runs (`--non-interactive` only applies safe migrations).
+- Doctor/CLI: scan for extra gateway-like services (optional `--deep`) and show cleanup hints.
- Gateway/CLI: auto-migrate legacy sessions + agent state layouts on startup (safe; WhatsApp auth still requires `clawdbot doctor`).
- Workspace: only create `BOOTSTRAP.md` for brand-new workspaces (don’t recreate after deletion).
- Build: fix duplicate protocol export, align Codex OAuth options, and add proper-lockfile typings.
@@ -78,6 +102,7 @@
- Typing indicators: fix a race that could keep the typing indicator stuck after quick replies. Thanks @thewilloftheshadow for PR #270.
- Google: merge consecutive messages to satisfy strict role alternation for Google provider models. Thanks @Asleep123 for PR #266.
- Postinstall: handle targetDir symlinks in the install script. Thanks @obviyus for PR #272.
+- Status: show configured model in `/status` (override-aware). Thanks @azade-c for PR #396.
- WhatsApp/Telegram: add groupPolicy handling for group messages and normalize allowFrom matching (tg/telegram prefixes). Thanks @mneves75.
- Auto-reply: add configurable ack reactions for inbound messages (default 👀 or `identity.emoji`) with scope controls. Thanks @obviyus for PR #178.
- Polls: unify WhatsApp + Discord poll sends via the gateway + CLI (`clawdbot poll`). (#123) — thanks @dbhurley
@@ -117,6 +142,7 @@
- Control UI: show a reading indicator bubble while the assistant is responding.
- Control UI: animate reading indicator dots (honors reduced-motion).
- Control UI: stabilize chat streaming during tool runs (no flicker/vanishing text; correct run scoping).
+- Google: recover from corrupted transcripts that start with an assistant tool call to avoid Cloud Code Assist 400 ordering errors. Thanks @jonasjancarik for PR #421. (#406)
- Control UI: let config-form enums select empty-string values. Thanks @sreekaransrinath for PR #268.
- Control UI: scroll chat to bottom on initial load. Thanks @kiranjd for PR #274.
- Control UI: add Chat focus mode toggle to collapse header + sidebar.
@@ -171,6 +197,11 @@
- Refactor: centralize group allowlist/mention policy across providers.
- Deps: update to latest across the repo.
+## 2026.1.7
+
+### Fixes
+- Android: bump version to 2026.1.7, add version code, and name APK outputs. Thanks @fcatuhe for PR #402.
+
## 2026.1.5-3
### Fixes
diff --git a/README.md b/README.md
index 83e8620f3..03c446cc1 100644
--- a/README.md
+++ b/README.md
@@ -454,5 +454,5 @@ Thanks to all clawtributors:
-
+
diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts
index e4f3c193a..009f08904 100644
--- a/apps/android/app/build.gradle.kts
+++ b/apps/android/app/build.gradle.kts
@@ -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()
+ .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)
diff --git a/apps/android/app/src/main/java/com/clawdbot/android/node/CameraCaptureManager.kt b/apps/android/app/src/main/java/com/clawdbot/android/node/CameraCaptureManager.kt
index 514524491..69a8a13c9 100644
--- a/apps/android/app/src/main/java/com/clawdbot/android/node/CameraCaptureManager.kt
+++ b/apps/android/app/src/main/java/com/clawdbot/android/node/CameraCaptureManager.kt
@@ -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 =
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 {
diff --git a/apps/macos/Sources/Clawdbot/AgentEventsWindow.swift b/apps/macos/Sources/Clawdbot/AgentEventsWindow.swift
index f37961ae3..e3ccc87bc 100644
--- a/apps/macos/Sources/Clawdbot/AgentEventsWindow.swift
+++ b/apps/macos/Sources/Clawdbot/AgentEventsWindow.swift
@@ -1,3 +1,4 @@
+import ClawdbotProtocol
import SwiftUI
@MainActor
diff --git a/apps/macos/Sources/Clawdbot/AnyCodable.swift b/apps/macos/Sources/Clawdbot/AnyCodable.swift
deleted file mode 100644
index 7c9a4668d..000000000
--- a/apps/macos/Sources/Clawdbot/AnyCodable.swift
+++ /dev/null
@@ -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)
- }
- }
-}
diff --git a/apps/macos/Sources/Clawdbot/Bridge/BridgeServer.swift b/apps/macos/Sources/Clawdbot/Bridge/BridgeServer.swift
index 60d01459a..4b71e6dec 100644
--- a/apps/macos/Sources/Clawdbot/Bridge/BridgeServer.swift
+++ b/apps/macos/Sources/Clawdbot/Bridge/BridgeServer.swift
@@ -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)
}
diff --git a/apps/macos/Sources/Clawdbot/ClawdbotConfigFile.swift b/apps/macos/Sources/Clawdbot/ClawdbotConfigFile.swift
index 433b3e1c8..83d38b79a 100644
--- a/apps/macos/Sources/Clawdbot/ClawdbotConfigFile.swift
+++ b/apps/macos/Sources/Clawdbot/ClawdbotConfigFile.swift
@@ -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()
diff --git a/apps/macos/Sources/Clawdbot/ClawdbotPaths.swift b/apps/macos/Sources/Clawdbot/ClawdbotPaths.swift
index 9cd7985ea..3e32782c0 100644
--- a/apps/macos/Sources/Clawdbot/ClawdbotPaths.swift
+++ b/apps/macos/Sources/Clawdbot/ClawdbotPaths.swift
@@ -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
}
diff --git a/apps/macos/Sources/Clawdbot/ConfigStore.swift b/apps/macos/Sources/Clawdbot/ConfigStore.swift
index 9090dd1d8..93b10cff4 100644
--- a/apps/macos/Sources/Clawdbot/ConfigStore.swift
+++ b/apps/macos/Sources/Clawdbot/ConfigStore.swift
@@ -1,3 +1,4 @@
+import ClawdbotProtocol
import Foundation
enum ConfigStore {
diff --git a/apps/macos/Sources/Clawdbot/CronJobEditor+Helpers.swift b/apps/macos/Sources/Clawdbot/CronJobEditor+Helpers.swift
index ec07cc5e4..877c0c6c7 100644
--- a/apps/macos/Sources/Clawdbot/CronJobEditor+Helpers.swift
+++ b/apps/macos/Sources/Clawdbot/CronJobEditor+Helpers.swift
@@ -1,3 +1,4 @@
+import ClawdbotProtocol
import Foundation
import SwiftUI
diff --git a/apps/macos/Sources/Clawdbot/CronJobEditor.swift b/apps/macos/Sources/Clawdbot/CronJobEditor.swift
index 93d2615bf..144368bf1 100644
--- a/apps/macos/Sources/Clawdbot/CronJobEditor.swift
+++ b/apps/macos/Sources/Clawdbot/CronJobEditor.swift
@@ -1,3 +1,4 @@
+import ClawdbotProtocol
import SwiftUI
struct CronJobEditor: View {
diff --git a/apps/macos/Sources/Clawdbot/CronSettings+Actions.swift b/apps/macos/Sources/Clawdbot/CronSettings+Actions.swift
index 8ae63704b..0de686bad 100644
--- a/apps/macos/Sources/Clawdbot/CronSettings+Actions.swift
+++ b/apps/macos/Sources/Clawdbot/CronSettings+Actions.swift
@@ -1,3 +1,4 @@
+import ClawdbotProtocol
import Foundation
extension CronSettings {
diff --git a/apps/macos/Sources/Clawdbot/GatewayAgentChannel.swift b/apps/macos/Sources/Clawdbot/GatewayAgentChannel.swift
index da96723a1..69e70b2b6 100644
--- a/apps/macos/Sources/Clawdbot/GatewayAgentChannel.swift
+++ b/apps/macos/Sources/Clawdbot/GatewayAgentChannel.swift
@@ -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
}
}
}
-
diff --git a/apps/macos/Sources/Clawdbot/GatewayDiscoveryModel.swift b/apps/macos/Sources/Clawdbot/GatewayDiscoveryModel.swift
index 5770670d1..384f9ca4f 100644
--- a/apps/macos/Sources/Clawdbot/GatewayDiscoveryModel.swift
+++ b/apps/macos/Sources/Clawdbot/GatewayDiscoveryModel.swift
@@ -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 {
diff --git a/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift b/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift
index ee6b2e8e1..a4b718f35 100644
--- a/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift
+++ b/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift
@@ -43,25 +43,52 @@ enum GatewayLaunchAgentManager {
return [gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind]
}
- static func status() async -> Bool {
+ static func isLoaded() async -> Bool {
guard FileManager.default.fileExists(atPath: self.plistURL.path) else { return false }
- let result = await self.runLaunchctl(["print", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
+ let result = await Launchctl.run(["print", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
return result.status == 0
}
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
if enabled {
- _ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(self.legacyGatewayLaunchdLabel)"])
+ _ = await Launchctl.run(["bootout", "gui/\(getuid())/\(self.legacyGatewayLaunchdLabel)"])
try? FileManager.default.removeItem(at: self.legacyPlistURL)
let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath)
guard FileManager.default.isExecutableFile(atPath: gatewayBin) else {
self.logger.error("launchd enable failed: gateway missing at \(gatewayBin)")
return "Embedded gateway missing in bundle; rebuild via scripts/package-mac-app.sh"
}
- self.logger.info("launchd enable requested port=\(port)")
+
+ let desiredBind = self.preferredGatewayBind() ?? "loopback"
+ let desiredToken = self.preferredGatewayToken()
+ let desiredPassword = self.preferredGatewayPassword()
+ let desiredConfig = DesiredConfig(
+ port: port,
+ bind: desiredBind,
+ token: desiredToken,
+ password: desiredPassword)
+
+ // If launchd already loaded the job (common on login), avoid `bootout` unless we must
+ // change the config. `bootout` can kill a just-started gateway and cause attach loops.
+ let loaded = await self.isLoaded()
+ if loaded,
+ let existing = self.readPlistConfig(),
+ existing.matches(desiredConfig)
+ {
+ self.logger.info("launchd job already loaded with desired config; skipping bootout")
+ await self.ensureEnabled()
+ _ = await Launchctl.run(["kickstart", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
+ return nil
+ }
+
+ self.logger.info("launchd enable requested port=\(port) bind=\(desiredBind)")
self.writePlist(bundlePath: bundlePath, port: port)
- _ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
- let bootstrap = await self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path])
+
+ await self.ensureEnabled()
+ if loaded {
+ _ = await Launchctl.run(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
+ }
+ let bootstrap = await Launchctl.run(["bootstrap", "gui/\(getuid())", self.plistURL.path])
if bootstrap.status != 0 {
let msg = bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines)
self.logger.error("launchd bootstrap failed: \(msg)")
@@ -69,20 +96,19 @@ enum GatewayLaunchAgentManager {
? "Failed to bootstrap gateway launchd job"
: bootstrap.output.trimmingCharacters(in: .whitespacesAndNewlines)
}
- // Note: removed redundant `kickstart -k` that caused race condition.
- // bootstrap already starts the job; kickstart -k would kill it immediately
- // and with KeepAlive=true, cause a restart loop with port conflicts.
+ await self.ensureEnabled()
return nil
}
self.logger.info("launchd disable requested")
- _ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
+ _ = await Launchctl.run(["bootout", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
+ await self.ensureDisabled()
try? FileManager.default.removeItem(at: self.plistURL)
return nil
}
static func kickstart() async {
- _ = await self.runLaunchctl(["kickstart", "-k", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
+ _ = await Launchctl.run(["kickstart", "-k", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
}
private static func writePlist(bundlePath: String, port: Int) {
@@ -208,30 +234,57 @@ enum GatewayLaunchAgentManager {
.replacingOccurrences(of: "'", with: "'")
}
- private struct LaunchctlResult {
- let status: Int32
- let output: String
+ private struct DesiredConfig: Equatable {
+ let port: Int
+ let bind: String
+ let token: String?
+ let password: String?
}
- @discardableResult
- private static func runLaunchctl(_ args: [String]) async -> LaunchctlResult {
- await Task.detached(priority: .utility) { () -> LaunchctlResult in
- let process = Process()
- process.launchPath = "/bin/launchctl"
- process.arguments = args
- let pipe = Pipe()
- process.standardOutput = pipe
- process.standardError = pipe
- do {
- try process.run()
- process.waitUntilExit()
- let data = pipe.fileHandleForReading.readToEndSafely()
- let output = String(data: data, encoding: .utf8) ?? ""
- return LaunchctlResult(status: process.terminationStatus, output: output)
- } catch {
- return LaunchctlResult(status: -1, output: error.localizedDescription)
- }
- }.value
+ private struct InstalledConfig: Equatable {
+ let port: Int?
+ let bind: String?
+ let token: String?
+ let password: String?
+
+ func matches(_ desired: DesiredConfig) -> Bool {
+ guard self.port == desired.port else { return false }
+ guard (self.bind ?? "loopback") == desired.bind else { return false }
+ guard self.token == desired.token else { return false }
+ guard self.password == desired.password else { return false }
+ return true
+ }
+ }
+
+ private static func readPlistConfig() -> InstalledConfig? {
+ guard let snapshot = LaunchAgentPlist.snapshot(url: self.plistURL) else { return nil }
+ return InstalledConfig(
+ port: snapshot.port,
+ bind: snapshot.bind,
+ token: snapshot.token,
+ password: snapshot.password)
+ }
+
+ private static func ensureEnabled() async {
+ let result = await Launchctl.run(["enable", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
+ guard result.status != 0 else { return }
+ let msg = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
+ if msg.isEmpty {
+ self.logger.warning("launchd enable failed")
+ } else {
+ self.logger.warning("launchd enable failed: \(msg)")
+ }
+ }
+
+ private static func ensureDisabled() async {
+ let result = await Launchctl.run(["disable", "gui/\(getuid())/\(gatewayLaunchdLabel)"])
+ guard result.status != 0 else { return }
+ let msg = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
+ if msg.isEmpty {
+ self.logger.warning("launchd disable failed")
+ } else {
+ self.logger.warning("launchd disable failed: \(msg)")
+ }
}
}
diff --git a/apps/macos/Sources/Clawdbot/GatewayProcessManager.swift b/apps/macos/Sources/Clawdbot/GatewayProcessManager.swift
index 62745cc66..3d046d855 100644
--- a/apps/macos/Sources/Clawdbot/GatewayProcessManager.swift
+++ b/apps/macos/Sources/Clawdbot/GatewayProcessManager.swift
@@ -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()
diff --git a/apps/macos/Sources/Clawdbot/Launchctl.swift b/apps/macos/Sources/Clawdbot/Launchctl.swift
new file mode 100644
index 000000000..ba52bb96b
--- /dev/null
+++ b/apps/macos/Sources/Clawdbot/Launchctl.swift
@@ -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
+ }
+}
diff --git a/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift b/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift
index c7fdcb545..8c2e01656 100644
--- a/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift
+++ b/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift
@@ -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
}
diff --git a/apps/macos/Sources/Clawdbot/MenuUsageHeaderView.swift b/apps/macos/Sources/Clawdbot/MenuUsageHeaderView.swift
index 199b01cf1..73152143d 100644
--- a/apps/macos/Sources/Clawdbot/MenuUsageHeaderView.swift
+++ b/apps/macos/Sources/Clawdbot/MenuUsageHeaderView.swift
@@ -42,4 +42,3 @@ struct MenuUsageHeaderView: View {
return "\(self.count) providers"
}
}
-
diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift
index cf0e28372..b439c66ea 100644
--- a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift
+++ b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift
@@ -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()
diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntimeMainActorServices.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntimeMainActorServices.swift
new file mode 100644
index 000000000..a6e03e3e3
--- /dev/null
+++ b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntimeMainActorServices.swift
@@ -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)
+ }
+}
diff --git a/apps/macos/Sources/Clawdbot/ProcessInfo+Clawdbot.swift b/apps/macos/Sources/Clawdbot/ProcessInfo+Clawdbot.swift
index b4d037018..29f9e7251 100644
--- a/apps/macos/Sources/Clawdbot/ProcessInfo+Clawdbot.swift
+++ b/apps/macos/Sources/Clawdbot/ProcessInfo+Clawdbot.swift
@@ -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")
}
diff --git a/apps/macos/Sources/Clawdbot/RemotePortTunnel.swift b/apps/macos/Sources/Clawdbot/RemotePortTunnel.swift
index cf0818b28..34e952540 100644
--- a/apps/macos/Sources/Clawdbot/RemotePortTunnel.swift
+++ b/apps/macos/Sources/Clawdbot/RemotePortTunnel.swift
@@ -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(
diff --git a/apps/macos/Sources/Clawdbot/UsageData.swift b/apps/macos/Sources/Clawdbot/UsageData.swift
index 0db492938..2318d98e8 100644
--- a/apps/macos/Sources/Clawdbot/UsageData.swift
+++ b/apps/macos/Sources/Clawdbot/UsageData.swift
@@ -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)
}
}
-
diff --git a/apps/macos/Sources/Clawdbot/UsageMenuLabelView.swift b/apps/macos/Sources/Clawdbot/UsageMenuLabelView.swift
index c5514a53d..4b1193e2f 100644
--- a/apps/macos/Sources/Clawdbot/UsageMenuLabelView.swift
+++ b/apps/macos/Sources/Clawdbot/UsageMenuLabelView.swift
@@ -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)
}
}
-
diff --git a/apps/macos/Sources/Clawdbot/WorkActivityStore.swift b/apps/macos/Sources/Clawdbot/WorkActivityStore.swift
index 47d241ace..9ab5b93d4 100644
--- a/apps/macos/Sources/Clawdbot/WorkActivityStore.swift
+++ b/apps/macos/Sources/Clawdbot/WorkActivityStore.swift
@@ -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] {
diff --git a/apps/macos/Tests/ClawdbotIPCTests/AgentEventStoreTests.swift b/apps/macos/Tests/ClawdbotIPCTests/AgentEventStoreTests.swift
index 1353c8b4f..1b0e75207 100644
--- a/apps/macos/Tests/ClawdbotIPCTests/AgentEventStoreTests.swift
+++ b/apps/macos/Tests/ClawdbotIPCTests/AgentEventStoreTests.swift
@@ -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))
}
diff --git a/apps/macos/Tests/ClawdbotIPCTests/AnyCodableEncodingTests.swift b/apps/macos/Tests/ClawdbotIPCTests/AnyCodableEncodingTests.swift
index 897ab6433..cb1cec109 100644
--- a/apps/macos/Tests/ClawdbotIPCTests/AnyCodableEncodingTests.swift
+++ b/apps/macos/Tests/ClawdbotIPCTests/AnyCodableEncodingTests.swift
@@ -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"])
diff --git a/apps/macos/Tests/ClawdbotIPCTests/ClawdbotConfigFileTests.swift b/apps/macos/Tests/ClawdbotIPCTests/ClawdbotConfigFileTests.swift
index b976541f6..9ee97e22c 100644
--- a/apps/macos/Tests/ClawdbotIPCTests/ClawdbotConfigFileTests.swift
+++ b/apps/macos/Tests/ClawdbotIPCTests/ClawdbotConfigFileTests.swift
@@ -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()
- }
}
diff --git a/apps/macos/Tests/ClawdbotIPCTests/CronJobEditorSmokeTests.swift b/apps/macos/Tests/ClawdbotIPCTests/CronJobEditorSmokeTests.swift
index efa369e5c..ea5f86579 100644
--- a/apps/macos/Tests/ClawdbotIPCTests/CronJobEditorSmokeTests.swift
+++ b/apps/macos/Tests/ClawdbotIPCTests/CronJobEditorSmokeTests.swift
@@ -35,7 +35,7 @@ struct CronJobEditorSmokeTests {
thinking: "low",
timeoutSeconds: 120,
deliver: true,
- channel: "whatsapp",
+ provider: "whatsapp",
to: "+15551234567",
bestEffortDeliver: true),
isolation: CronIsolation(postToMainPrefix: "Cron"),
diff --git a/apps/macos/Tests/ClawdbotIPCTests/CronModelsTests.swift b/apps/macos/Tests/ClawdbotIPCTests/CronModelsTests.swift
index fe478ac14..81ef2e96e 100644
--- a/apps/macos/Tests/ClawdbotIPCTests/CronModelsTests.swift
+++ b/apps/macos/Tests/ClawdbotIPCTests/CronModelsTests.swift
@@ -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)
diff --git a/apps/macos/Tests/ClawdbotIPCTests/GatewayChannelConfigureTests.swift b/apps/macos/Tests/ClawdbotIPCTests/GatewayChannelConfigureTests.swift
index 7893dafe7..22c83d4fd 100644
--- a/apps/macos/Tests/ClawdbotIPCTests/GatewayChannelConfigureTests.swift
+++ b/apps/macos/Tests/ClawdbotIPCTests/GatewayChannelConfigureTests.swift
@@ -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)
diff --git a/apps/macos/Tests/ClawdbotIPCTests/GatewayEnvironmentTests.swift b/apps/macos/Tests/ClawdbotIPCTests/GatewayEnvironmentTests.swift
index 0e4e35e6f..20d5b5973 100644
--- a/apps/macos/Tests/ClawdbotIPCTests/GatewayEnvironmentTests.swift
+++ b/apps/macos/Tests/ClawdbotIPCTests/GatewayEnvironmentTests.swift
@@ -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() {
diff --git a/apps/macos/Tests/ClawdbotIPCTests/GatewayLaunchAgentManagerTests.swift b/apps/macos/Tests/ClawdbotIPCTests/GatewayLaunchAgentManagerTests.swift
new file mode 100644
index 000000000..ae8357b0c
--- /dev/null
+++ b/apps/macos/Tests/ClawdbotIPCTests/GatewayLaunchAgentManagerTests.swift
@@ -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)
+ }
+}
diff --git a/apps/macos/Tests/ClawdbotIPCTests/LowCoverageHelperTests.swift b/apps/macos/Tests/ClawdbotIPCTests/LowCoverageHelperTests.swift
index e7b745b08..6ee7cc012 100644
--- a/apps/macos/Tests/ClawdbotIPCTests/LowCoverageHelperTests.swift
+++ b/apps/macos/Tests/ClawdbotIPCTests/LowCoverageHelperTests.swift
@@ -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\"'") ==
+ "a&b<c>"'")
+
+ #expect(GatewayLaunchAgentManager._testGatewayExecutablePath(bundlePath: "/App") == "/App/Contents/Resources/Relay/clawdbot")
+ #expect(GatewayLaunchAgentManager._testRelayDir(bundlePath: "/App") == "/App/Contents/Resources/Relay")
}
-
- setenv(keyBind, "Lan", 1)
- setenv(keyToken, " secret ", 1)
- #expect(GatewayLaunchAgentManager._testPreferredGatewayBind() == "lan")
- #expect(GatewayLaunchAgentManager._testPreferredGatewayToken() == "secret")
- #expect(
- GatewayLaunchAgentManager._testEscapePlistValue("a&b\"'") ==
- "a&b<c>"'")
-
- #expect(GatewayLaunchAgentManager._testGatewayExecutablePath(bundlePath: "/App") == "/App/Contents/Resources/Relay/clawdbot")
- #expect(GatewayLaunchAgentManager._testRelayDir(bundlePath: "/App") == "/App/Contents/Resources/Relay")
}
@Test func portGuardianParsesListenersAndBuildsReports() {
diff --git a/apps/macos/Tests/ClawdbotIPCTests/LowCoverageViewSmokeTests.swift b/apps/macos/Tests/ClawdbotIPCTests/LowCoverageViewSmokeTests.swift
index 98018035b..27aff597e 100644
--- a/apps/macos/Tests/ClawdbotIPCTests/LowCoverageViewSmokeTests.swift
+++ b/apps/macos/Tests/ClawdbotIPCTests/LowCoverageViewSmokeTests.swift
@@ -1,6 +1,7 @@
import AppKit
import SwiftUI
import Testing
+import ClawdbotProtocol
@testable import Clawdbot
diff --git a/apps/macos/Tests/ClawdbotIPCTests/MacNodeRuntimeTests.swift b/apps/macos/Tests/ClawdbotIPCTests/MacNodeRuntimeTests.swift
index 7b64265f5..2dd408f1f 100644
--- a/apps/macos/Tests/ClawdbotIPCTests/MacNodeRuntimeTests.swift
+++ b/apps/macos/Tests/ClawdbotIPCTests/MacNodeRuntimeTests.swift
@@ -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)
}
}
diff --git a/apps/macos/Tests/ClawdbotIPCTests/MenuSessionsInjectorTests.swift b/apps/macos/Tests/ClawdbotIPCTests/MenuSessionsInjectorTests.swift
index 2e5bafcfd..cae8b7be4 100644
--- a/apps/macos/Tests/ClawdbotIPCTests/MenuSessionsInjectorTests.swift
+++ b/apps/macos/Tests/ClawdbotIPCTests/MenuSessionsInjectorTests.swift
@@ -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,
diff --git a/apps/macos/Tests/ClawdbotIPCTests/SessionDataTests.swift b/apps/macos/Tests/ClawdbotIPCTests/SessionDataTests.swift
index 96f21ab0c..d52c9aecb 100644
--- a/apps/macos/Tests/ClawdbotIPCTests/SessionDataTests.swift
+++ b/apps/macos/Tests/ClawdbotIPCTests/SessionDataTests.swift
@@ -28,7 +28,7 @@ struct SessionDataTests {
key: "user@example.com",
kind: .direct,
displayName: nil,
- surface: nil,
+ provider: nil,
subject: nil,
room: nil,
space: nil,
diff --git a/apps/macos/Tests/ClawdbotIPCTests/SettingsViewSmokeTests.swift b/apps/macos/Tests/ClawdbotIPCTests/SettingsViewSmokeTests.swift
index d3fe9e07d..c59aba43a 100644
--- a/apps/macos/Tests/ClawdbotIPCTests/SettingsViewSmokeTests.swift
+++ b/apps/macos/Tests/ClawdbotIPCTests/SettingsViewSmokeTests.swift
@@ -45,7 +45,7 @@ struct SettingsViewSmokeTests {
thinking: "low",
timeoutSeconds: 30,
deliver: true,
- channel: "sms",
+ provider: "sms",
to: "+15551234567",
bestEffortDeliver: true),
isolation: CronIsolation(postToMainPrefix: "[cron] "),
diff --git a/apps/macos/Tests/ClawdbotIPCTests/SkillsSettingsSmokeTests.swift b/apps/macos/Tests/ClawdbotIPCTests/SkillsSettingsSmokeTests.swift
index afa028dcf..f2d8a61bf 100644
--- a/apps/macos/Tests/ClawdbotIPCTests/SkillsSettingsSmokeTests.swift
+++ b/apps/macos/Tests/ClawdbotIPCTests/SkillsSettingsSmokeTests.swift
@@ -1,4 +1,5 @@
import Testing
+import ClawdbotProtocol
@testable import Clawdbot
@Suite(.serialized)
diff --git a/apps/macos/Tests/ClawdbotIPCTests/TestIsolation.swift b/apps/macos/Tests/ClawdbotIPCTests/TestIsolation.swift
new file mode 100644
index 000000000..03c32607f
--- /dev/null
+++ b/apps/macos/Tests/ClawdbotIPCTests/TestIsolation.swift
@@ -0,0 +1,116 @@
+import Foundation
+
+actor TestIsolationLock {
+ static let shared = TestIsolationLock()
+
+ private var locked = false
+ private var waiters: [CheckedContinuation] = []
+
+ 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(
+ 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(
+ _ values: [String: String?],
+ _ body: () async throws -> T) async rethrows -> T
+ {
+ try await Self.withIsolatedState(env: values, defaults: [:], body)
+ }
+
+ static func withUserDefaultsValues(
+ _ 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
+ }
+}
diff --git a/apps/macos/Tests/ClawdbotIPCTests/VoiceWakeForwarderTests.swift b/apps/macos/Tests/ClawdbotIPCTests/VoiceWakeForwarderTests.swift
index b8318c3fe..35a96626b 100644
--- a/apps/macos/Tests/ClawdbotIPCTests/VoiceWakeForwarderTests.swift
+++ b/apps/macos/Tests/ClawdbotIPCTests/VoiceWakeForwarderTests.swift
@@ -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)
}
}
diff --git a/apps/macos/Tests/ClawdbotIPCTests/WorkActivityStoreTests.swift b/apps/macos/Tests/ClawdbotIPCTests/WorkActivityStoreTests.swift
index 50a4e69d6..983c394b3 100644
--- a/apps/macos/Tests/ClawdbotIPCTests/WorkActivityStoreTests.swift
+++ b/apps/macos/Tests/ClawdbotIPCTests/WorkActivityStoreTests.swift
@@ -1,5 +1,6 @@
import Foundation
import Testing
+import ClawdbotProtocol
@testable import Clawdbot
@Suite
diff --git a/apps/shared/ClawdbotKit/Resources/tool-display.json b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/Resources/tool-display.json
similarity index 100%
rename from apps/shared/ClawdbotKit/Resources/tool-display.json
rename to apps/shared/ClawdbotKit/Sources/ClawdbotKit/Resources/tool-display.json
diff --git a/docs/assets/markdown.css b/docs/assets/markdown.css
index c6acd9785..6ad456334 100644
--- a/docs/assets/markdown.css
+++ b/docs/assets/markdown.css
@@ -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;
diff --git a/docs/assets/showcase/gohome-grafana.png b/docs/assets/showcase/gohome-grafana.png
new file mode 100644
index 000000000..bd7cf0774
Binary files /dev/null and b/docs/assets/showcase/gohome-grafana.png differ
diff --git a/docs/assets/showcase/padel-cli.svg b/docs/assets/showcase/padel-cli.svg
new file mode 100644
index 000000000..61eb6334d
--- /dev/null
+++ b/docs/assets/showcase/padel-cli.svg
@@ -0,0 +1,11 @@
+
diff --git a/docs/assets/showcase/padel-screenshot.jpg b/docs/assets/showcase/padel-screenshot.jpg
new file mode 100644
index 000000000..eb1ae39ea
Binary files /dev/null and b/docs/assets/showcase/padel-screenshot.jpg differ
diff --git a/docs/assets/showcase/roborock-screenshot.jpg b/docs/assets/showcase/roborock-screenshot.jpg
new file mode 100644
index 000000000..e31ba11eb
Binary files /dev/null and b/docs/assets/showcase/roborock-screenshot.jpg differ
diff --git a/docs/assets/showcase/roborock-status.svg b/docs/assets/showcase/roborock-status.svg
new file mode 100644
index 000000000..470840423
--- /dev/null
+++ b/docs/assets/showcase/roborock-status.svg
@@ -0,0 +1,13 @@
+
diff --git a/docs/assets/showcase/xuezh-pronunciation.jpeg b/docs/assets/showcase/xuezh-pronunciation.jpeg
new file mode 100644
index 000000000..7f7d86a8f
Binary files /dev/null and b/docs/assets/showcase/xuezh-pronunciation.jpeg differ
diff --git a/docs/cli/index.md b/docs/cli/index.md
index a12dbcf2c..d7c4faca0 100644
--- a/docs/cli/index.md
+++ b/docs/cli/index.md
@@ -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 `
Gateway RPC helpers (use `--url`, `--token`, `--password`, `--timeout`, `--expect-final` for each).
@@ -372,8 +394,12 @@ Subcommands:
- `gateway wake --text [--mode now|next-heartbeat]`
- `gateway send --to --message [--media-url ] [--gif-playback] [--idempotency-key ]`
- `gateway agent --message [--to ] [--session-id ] [--thinking ] [--deliver] [--timeout-seconds ] [--idempotency-key ]`
+- `gateway install`
+- `gateway uninstall`
+- `gateway start`
- `gateway stop`
- `gateway restart`
+- `gateway daemon status` (alias for `clawdbot daemon status`)
## Models
diff --git a/docs/concepts/compaction.md b/docs/concepts/compaction.md
new file mode 100644
index 000000000..4ec1bfdfd
--- /dev/null
+++ b/docs/concepts/compaction.md
@@ -0,0 +1,43 @@
+---
+summary: "Context window + compaction: how Clawdbot keeps sessions under model limits"
+read_when:
+ - You want to understand auto-compaction and /compact
+ - You are debugging long sessions hitting context limits
+---
+# Context Window & Compaction
+
+Every model has a **context window** (max tokens it can see). Long-running chats accumulate messages and tool results; once the window is tight, Clawdbot **compacts** older history to stay within limits.
+
+## What compaction is
+Compaction **summarizes older conversation** into a compact summary entry and keeps recent messages intact. The summary is stored in the session history, so future requests use:
+- The compaction summary
+- Recent messages after the compaction point
+
+Compaction **persists** in the session’s JSONL history.
+
+## Auto-compaction (default on)
+When a session nears or exceeds the model’s context window, Clawdbot triggers auto-compaction and may retry the original request using the compacted context.
+
+You’ll see:
+- `🧹 Auto-compaction complete` in verbose mode
+- `/status` showing `🧹 Compactions: `
+
+## 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.
diff --git a/docs/concepts/group-messages.md b/docs/concepts/group-messages.md
index e403634d2..358a64c95 100644
--- a/docs/concepts/group-messages.md
+++ b/docs/concepts/group-messages.md
@@ -71,3 +71,4 @@ Only the owner number (from `whatsapp.allowFrom`, or the bot’s own E.164 when
- Heartbeats are intentionally skipped for groups to avoid noisy broadcasts.
- Echo suppression uses the combined batch string; if you send identical text twice without mentions, only the first will get a response.
- Session store entries will appear as `agent::whatsapp:group:` in the session store (`~/.clawdbot/agents//sessions/sessions.json` by default); a missing entry just means the group hasn’t triggered a run yet.
+- Typing indicators in groups follow `agent.typingMode` (default: `message` when unmentioned).
diff --git a/docs/concepts/models.md b/docs/concepts/models.md
index b7fba7b12..7ad93c347 100644
--- a/docs/concepts/models.md
+++ b/docs/concepts/models.md
@@ -12,6 +12,23 @@ See [`docs/model-failover.md`](/concepts/model-failover) for how auth profiles r
Goal: give clear model visibility + control (configured vs available), plus scan tooling
that prefers tool-call + image-capable models and maintains ordered fallbacks.
+## How Clawdbot models work (quick explainer)
+
+Clawdbot selects models in this order:
+1) The configured **primary** model (`agent.model.primary`).
+2) If it fails, fallbacks in `agent.model.fallbacks` (in order).
+3) Auth failover happens **inside** the provider first (see [/concepts/model-failover](/concepts/model-failover)).
+
+Key pieces:
+- `provider/model` is the canonical model id (e.g. `anthropic/claude-opus-4-5`).
+- `agent.models` is the **allowlist/catalog** of models Clawdbot can use, with optional aliases.
+- `agent.imageModel` is only used when the primary model **can’t** accept images.
+- `models.providers` lets you add custom providers + models (written to `models.json`).
+- `/model ` switches the active model for the current session; `/model list` shows what’s allowed.
+
+Related:
+- Context limits are model-specific; long sessions may trigger compaction. See [/concepts/compaction](/concepts/compaction).
+
## Model recommendations
Through testing, we’ve found [Claude Opus 4.5](https://www.anthropic.com/claude/opus) is the most useful general-purpose model for anything coding-related. We suggest [GPT-5.2-Codex](https://developers.openai.com/codex/models) for coding and sub-agents. For personal assistant work, nothing comes close to Opus. If you’re going all-in on Claude, we recommend the [Claude Max $200 subscription](https://www.anthropic.com/pricing/).
@@ -45,6 +62,33 @@ Anecdotal notes from the Discord thread on January 4–5, 2026. Treat as “what
See [/cli](/cli) for the full command tree and CLI flags.
+### CLI output (list + status)
+
+`clawdbot models list` (default) prints a table with these columns:
+- `Model`: `provider/model` key (truncated in TTY).
+- `Input`: `text` or `text+image`.
+- `Ctx`: context window in K tokens (from the model registry).
+- `Local`: `yes/no` when the provider base URL is local.
+- `Auth`: `yes/no` when the provider has usable auth.
+- `Tags`: origin + role hints.
+
+Common tags:
+- `default` — resolved default model.
+- `fallback#N` — `agent.model.fallbacks` order.
+- `image` — `agent.imageModel.primary`.
+- `img-fallback#N` — `agent.imageModel.fallbacks` order.
+- `configured` — present in `agent.models`.
+- `alias:` — 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).
diff --git a/docs/concepts/retry.md b/docs/concepts/retry.md
new file mode 100644
index 000000000..ca9b32c03
--- /dev/null
+++ b/docs/concepts/retry.md
@@ -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.
diff --git a/docs/concepts/session-pruning.md b/docs/concepts/session-pruning.md
new file mode 100644
index 000000000..d59b77b6e
--- /dev/null
+++ b/docs/concepts/session-pruning.md
@@ -0,0 +1,92 @@
+---
+summary: "Session pruning: opt-in tool-result trimming to reduce context bloat"
+read_when:
+ - You want to reduce LLM context growth from tool outputs
+ - You are tuning agent.contextPruning
+---
+# Session Pruning
+
+Session pruning trims **old tool results** from the in-memory context right before each LLM call. It is **opt-in** and does **not** rewrite the on-disk session history (`*.jsonl`).
+
+## When it runs
+- Before each LLM request (context hook).
+- Only affects the messages sent to the model for that request.
+
+## What can be pruned
+- Only `toolResult` messages.
+- User + assistant messages are **never** modified.
+- The last `keepLastAssistants` assistant messages are protected; tool results after that cutoff are not pruned.
+- If there aren’t enough assistant messages to establish the cutoff, pruning is skipped.
+- Tool results containing **image blocks** are skipped (never trimmed/cleared).
+
+## Context window estimation
+Pruning uses an estimated context window (chars ≈ tokens × 4). The window size is resolved in this order:
+1) Model definition `contextWindow` (from the model registry).
+2) `models.providers.*.models[].contextWindow` override.
+3) `agent.contextTokens`.
+4) Default `200000` tokens.
+
+## Modes
+### adaptive
+- If estimated context ratio ≥ `softTrimRatio`: soft-trim oversized tool results.
+- If still ≥ `hardClearRatio` **and** prunable tool text ≥ `minPrunableToolChars`: hard-clear oldest eligible tool results.
+
+### aggressive
+- Always hard-clears eligible tool results before the cutoff.
+- Ignores `hardClear.enabled` (always clears when eligible).
+
+## Soft vs hard pruning
+- **Soft-trim**: only for oversized tool results.
+ - Keeps head + tail, inserts `...`, and appends a note with the original size.
+ - Skips results with image blocks.
+- **Hard-clear**: replaces the entire tool result with `hardClear.placeholder`.
+
+## Tool selection
+- `tools.allow` / `tools.deny` support `*` wildcards.
+- Deny wins.
+- Empty allow list => all tools allowed.
+
+## Interaction with other limits
+- Built-in tools already truncate their own output; session pruning is an extra layer that prevents long-running chats from accumulating too much tool output in the model context.
+- Compaction is separate: compaction summarizes and persists, pruning is transient per request. See [/concepts/compaction](/concepts/compaction).
+
+## Defaults (when enabled)
+- `keepLastAssistants`: `3`
+- `softTrimRatio`: `0.3`
+- `hardClearRatio`: `0.5`
+- `minPrunableToolChars`: `50000`
+- `softTrim`: `{ maxChars: 4000, headChars: 1500, tailChars: 1500 }`
+- `hardClear`: `{ enabled: true, placeholder: "[Old tool result content cleared]" }`
+
+## Examples
+Minimal (adaptive):
+```json5
+{
+ agent: {
+ contextPruning: { mode: "adaptive" }
+ }
+}
+```
+
+Aggressive:
+```json5
+{
+ agent: {
+ contextPruning: { mode: "aggressive" }
+ }
+}
+```
+
+Restrict pruning to specific tools:
+```json5
+{
+ agent: {
+ contextPruning: {
+ mode: "adaptive",
+ tools: { allow: ["bash", "read"], deny: ["*image*"] }
+ }
+ }
+}
+```
+
+See config reference: [Gateway Configuration](/gateway/configuration)
diff --git a/docs/concepts/session-tool.md b/docs/concepts/session-tool.md
index de4f31fb2..d1e0cb343 100644
--- a/docs/concepts/session-tool.md
+++ b/docs/concepts/session-tool.md
@@ -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::subagent:` session with `deliver: false`.
+- Starts a new `agent::subagent:` 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).
diff --git a/docs/concepts/session.md b/docs/concepts/session.md
index 8cd144201..311015bea 100644
--- a/docs/concepts/session.md
+++ b/docs/concepts/session.md
@@ -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//sessions/sessions.json` (per agent).
- - Transcripts: `~/.clawdbot/agents//sessions/.jsonl` (one file per session id).
+- Transcripts: `~/.clawdbot/agents//sessions/.jsonl` (Telegram topic sessions use `.../-topic-.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::`.
- 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
diff --git a/docs/concepts/typing-indicators.md b/docs/concepts/typing-indicators.md
new file mode 100644
index 000000000..e3d92a46f
--- /dev/null
+++ b/docs/concepts/typing-indicators.md
@@ -0,0 +1,59 @@
+---
+summary: "When Clawdbot shows typing indicators and how to tune them"
+read_when:
+ - Changing typing indicator behavior or defaults
+---
+# Typing indicators
+
+Typing indicators are sent to the chat provider while a run is active. Use
+`agent.typingMode` to control **when** typing starts and `typingIntervalSeconds`
+to control **how often** it refreshes.
+
+## Defaults
+When `agent.typingMode` is **unset**, Clawdbot keeps the legacy behavior:
+- **Direct chats**: typing starts immediately once the model loop begins.
+- **Group chats with a mention**: typing starts immediately.
+- **Group chats without a mention**: typing starts only when message text begins streaming.
+- **Heartbeat runs**: typing is disabled.
+
+## Modes
+Set `agent.typingMode` to one of:
+- `never` — no typing indicator, ever.
+- `instant` — start typing **as soon as the model loop begins**, even if the run
+ later returns only the silent reply token.
+- `thinking` — start typing on the **first reasoning delta** (requires
+ `reasoningLevel: "stream"` for the run).
+- `message` — start typing on the **first non-silent text delta** (ignores
+ the `NO_REPLY` silent token).
+
+Order of “how early it fires”:
+`never` → `message` → `thinking` → `instant`
+
+## Configuration
+```json5
+{
+ agent: {
+ typingMode: "thinking",
+ typingIntervalSeconds: 6
+ }
+}
+```
+
+You can override mode or cadence per session:
+```json5
+{
+ session: {
+ typingMode: "message",
+ typingIntervalSeconds: 4
+ }
+}
+```
+
+## Notes
+- `message` mode won’t show typing for silent-only replies (e.g. the `NO_REPLY`
+ token used to suppress output).
+- `thinking` only fires if the run streams reasoning (`reasoningLevel: "stream"`).
+ If the model doesn’t emit reasoning deltas, typing won’t start.
+- Heartbeats never show typing, regardless of mode.
+- `typingIntervalSeconds` controls the **refresh cadence**, not the start time.
+ The default is 6 seconds.
diff --git a/docs/docs.json b/docs/docs.json
index e29cfd9c3..ca8a54b7d 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -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"
]
},
{
diff --git a/docs/gateway/background-process.md b/docs/gateway/background-process.md
index 49fdc7559..3f97c844b 100644
--- a/docs/gateway/background-process.md
+++ b/docs/gateway/background-process.md
@@ -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).
diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md
index 0b39a9580..7209d2967 100644
--- a/docs/gateway/configuration.md
+++ b/docs/gateway/configuration.md
@@ -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..users` on all messages (empty list disables).
+Retry policy defaults and behavior are documented in [Retry policy](/concepts/retry).
### `slack` (socket mode)
@@ -813,6 +896,88 @@ If you configure the same alias name (case-insensitive) yourself, your value win
}
```
+#### `agent.contextPruning` (opt-in tool-result pruning)
+
+`agent.contextPruning` prunes **old tool results** from the in-memory context right before a request is sent to the LLM.
+It does **not** modify the session history on disk (`*.jsonl` remains complete).
+
+This is intended to reduce token usage for chatty agents that accumulate large tool outputs over time.
+
+High level:
+- Never touches user/assistant messages.
+- Protects the last `keepLastAssistants` assistant messages (no tool results after that point are pruned).
+- Protects the bootstrap prefix (nothing before the first user message is pruned).
+- Modes:
+ - `adaptive`: soft-trims oversized tool results (keep head/tail) when the estimated context ratio crosses `softTrimRatio`.
+ Then hard-clears the oldest eligible tool results when the estimated context ratio crosses `hardClearRatio` **and**
+ there’s enough prunable tool-result bulk (`minPrunableToolChars`).
+ - `aggressive`: always replaces eligible tool results before the cutoff with the `hardClear.placeholder` (no ratio checks).
+
+Soft vs hard pruning (what changes in the context sent to the LLM):
+- **Soft-trim**: only for *oversized* tool results. Keeps the beginning + end and inserts `...` in the middle.
+ - Before: `toolResult("…very long output…")`
+ - After: `toolResult("HEAD…\n...\n…TAIL\n\n[Tool result trimmed: …]")`
+- **Hard-clear**: replaces the entire tool result with the placeholder.
+ - Before: `toolResult("…very long output…")`
+ - After: `toolResult("[Old tool result content cleared]")`
+
+Notes / current limitations:
+- Tool results containing **image blocks are skipped** (never trimmed/cleared) right now.
+- The estimated “context ratio” is based on **characters** (approximate), not exact tokens.
+- If the session doesn’t contain at least `keepLastAssistants` assistant messages yet, pruning is skipped.
+- In `aggressive` mode, `hardClear.enabled` is ignored (eligible tool results are always replaced with `hardClear.placeholder`).
+
+Example (minimal):
+```json5
+{
+ agent: {
+ contextPruning: {
+ mode: "adaptive"
+ }
+ }
+}
+```
+
+Defaults (when `mode` is `"adaptive"` or `"aggressive"`):
+- `keepLastAssistants`: `3`
+- `softTrimRatio`: `0.3` (adaptive only)
+- `hardClearRatio`: `0.5` (adaptive only)
+- `minPrunableToolChars`: `50000` (adaptive only)
+- `softTrim`: `{ maxChars: 4000, headChars: 1500, tailChars: 1500 }` (adaptive only)
+- `hardClear`: `{ enabled: true, placeholder: "[Old tool result content cleared]" }`
+
+Example (aggressive, minimal):
+```json5
+{
+ agent: {
+ contextPruning: {
+ mode: "aggressive"
+ }
+ }
+}
+```
+
+Example (adaptive tuned):
+```json5
+{
+ agent: {
+ contextPruning: {
+ mode: "adaptive",
+ keepLastAssistants: 3,
+ softTrimRatio: 0.3,
+ hardClearRatio: 0.5,
+ minPrunableToolChars: 50000,
+ softTrim: { maxChars: 4000, headChars: 1500, tailChars: 1500 },
+ hardClear: { enabled: true, placeholder: "[Old tool result content cleared]" },
+ // Optional: restrict pruning to specific tools (deny wins; supports "*" wildcards)
+ tools: { deny: ["browser", "canvas"] },
+ }
+ }
+}
+```
+
+See [/concepts/session-pruning](/concepts/session-pruning) for behavior details.
+
Block streaming:
- `agent.blockStreamingDefault`: `"on"`/`"off"` (default on).
- `agent.blockStreamingBreak`: `"text_end"` or `"message_end"` (default: text_end).
@@ -828,6 +993,14 @@ Block streaming:
```
See [/concepts/streaming](/concepts/streaming) for behavior + chunking details.
+Typing indicators:
+- `agent.typingMode`: `"never" | "instant" | "thinking" | "message"`. Defaults to
+ `instant` for direct chats / mentions and `message` for unmentioned group chats.
+- `session.typingMode`: per-session override for the mode.
+- `agent.typingIntervalSeconds`: how often the typing signal is refreshed (default: 6s).
+- `session.typingIntervalSeconds`: per-session override for the refresh interval.
+See [/concepts/typing-indicators](/concepts/typing-indicators) for behavior details.
+
`agent.model.primary` should be set as `provider/model` (e.g. `anthropic/claude-opus-4-5`).
Aliases come from `agent.models.*.alias` (e.g. `Opus`).
If you omit the provider, CLAWDBOT currently assumes `anthropic` as a temporary
diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md
index 38e9ee334..4075b88a3 100644
--- a/docs/gateway/doctor.md
+++ b/docs/gateway/doctor.md
@@ -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
diff --git a/docs/gateway/index.md b/docs/gateway/index.md
index f025dff31..416ee4682 100644
--- a/docs/gateway/index.md
+++ b/docs/gateway/index.md
@@ -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`).
diff --git a/docs/gateway/security.md b/docs/gateway/security.md
index d12dde53b..e09347746 100644
--- a/docs/gateway/security.md
+++ b/docs/gateway/security.md
@@ -128,12 +128,13 @@ Consider running your AI on a separate phone number from your personal one:
- Personal number: Your conversations stay private
- Bot number: AI handles these, with appropriate boundaries
-### 4. Read-Only Mode (Future)
+### 4. Read-Only Mode (Today, via sandbox + tools)
-We're considering a `readOnlyMode` flag that prevents the AI from:
-- Writing files outside a sandbox
-- Executing shell commands
-- Sending messages
+You can already build a read-only profile by combining:
+- `sandbox.workspaceAccess: "ro"` (or `"none"` for no workspace access)
+- tool allow/deny lists that block `write`, `edit`, `bash`, `process`, etc.
+
+We may add a single `readOnlyMode` flag later to simplify this configuration.
## Sandboxing (recommended)
@@ -153,6 +154,79 @@ Also consider agent workspace access inside the sandbox:
Important: `agent.elevated` is an explicit escape hatch that runs bash on the host. Keep `agent.elevated.allowFrom` tight and don’t enable it for strangers.
+## Per-agent access profiles (multi-agent)
+
+With multi-agent routing, each agent can have its own sandbox + tool policy:
+use this to give **full access**, **read-only**, or **no access** per agent.
+See [Multi-Agent Sandbox & Tools](/multi-agent-sandbox-tools) for full details
+and precedence rules.
+
+Common use cases:
+- Personal agent: full access, no sandbox
+- Family/work agent: sandboxed + read-only tools
+- Public agent: sandboxed + no filesystem/shell tools
+
+### Example: full access (no sandbox)
+
+```json5
+{
+ routing: {
+ agents: {
+ personal: {
+ workspace: "~/clawd-personal",
+ sandbox: { mode: "off" }
+ }
+ }
+ }
+}
+```
+
+### Example: read-only tools + read-only workspace
+
+```json5
+{
+ routing: {
+ agents: {
+ family: {
+ workspace: "~/clawd-family",
+ sandbox: {
+ mode: "all",
+ scope: "agent",
+ workspaceAccess: "ro"
+ },
+ tools: {
+ allow: ["read"],
+ deny: ["write", "edit", "bash", "process", "browser"]
+ }
+ }
+ }
+ }
+}
+```
+
+### Example: no filesystem/shell access (provider messaging allowed)
+
+```json5
+{
+ routing: {
+ agents: {
+ public: {
+ workspace: "~/clawd-public",
+ sandbox: {
+ mode: "all",
+ scope: "agent",
+ workspaceAccess: "none"
+ },
+ tools: {
+ allow: ["sessions_list", "sessions_history", "sessions_send", "sessions_spawn", "whatsapp", "telegram", "slack", "discord", "gateway"],
+ deny: ["read", "write", "edit", "bash", "process", "browser", "canvas", "nodes", "cron", "gateway", "image"]
+ }
+ }
+ }
+ }
+}
+```
+
## What to Tell Your AI
Include security guidelines in your agent's system prompt:
diff --git a/docs/install/docker.md b/docs/install/docker.md
index ed06679e9..0f3879de4 100644
--- a/docs/install/docker.md
+++ b/docs/install/docker.md
@@ -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`
diff --git a/docs/install/updating.md b/docs/install/updating.md
index 11846ccbc..f6e045c4e 100644
--- a/docs/install/updating.md
+++ b/docs/install/updating.md
@@ -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:
diff --git a/docs/platforms/android.md b/docs/platforms/android.md
index 56beab345..9e274da0a 100644
--- a/docs/platforms/android.md
+++ b/docs/platforms/android.md
@@ -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**
diff --git a/docs/platforms/index.md b/docs/platforms/index.md
new file mode 100644
index 000000000..9d388140f
--- /dev/null
+++ b/docs/platforms/index.md
@@ -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
diff --git a/docs/platforms/ios.md b/docs/platforms/ios.md
index 09cb80ce4..939d5c044 100644
--- a/docs/platforms/ios.md
+++ b/docs/platforms/ios.md
@@ -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:
diff --git a/docs/platforms/linux.md b/docs/platforms/linux.md
index b5e27e4cb..78348d698 100644
--- a/docs/platforms/linux.md
+++ b/docs/platforms/linux.md
@@ -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
+```
diff --git a/docs/platforms/macos.md b/docs/platforms/macos.md
index bac5c1539..a1daa37cc 100644
--- a/docs/platforms/macos.md
+++ b/docs/platforms/macos.md
@@ -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.
diff --git a/docs/platforms/windows.md b/docs/platforms/windows.md
index 67ad766c0..b97906295 100644
--- a/docs/platforms/windows.md
+++ b/docs/platforms/windows.md
@@ -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):
diff --git a/docs/providers/discord.md b/docs/providers/discord.md
index b4bfaf878..4d5d652c4 100644
--- a/docs/providers/discord.md
+++ b/docs/providers/discord.md
@@ -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..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`
diff --git a/docs/providers/telegram.md b/docs/providers/telegram.md
index 3772ebc81..a8722481f 100644
--- a/docs/providers/telegram.md
+++ b/docs/providers/telegram.md
@@ -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 `
- 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).
diff --git a/docs/providers/whatsapp.md b/docs/providers/whatsapp.md
index 42dfb0572..a777af70f 100644
--- a/docs/providers/whatsapp.md
+++ b/docs/providers/whatsapp.md
@@ -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 `; 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..*` (per-account settings + optional `authDir`).
- `whatsapp.groupAllowFrom` (group sender allowlist).
diff --git a/docs/start/faq.md b/docs/start/faq.md
index ad16dd0e7..e2849ad02 100644
--- a/docs/start/faq.md
+++ b/docs/start/faq.md
@@ -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.
diff --git a/docs/start/hubs.md b/docs/start/hubs.md
index 77b943b47..58b9209b7 100644
--- a/docs/start/hubs.md
+++ b/docs/start/hubs.md
@@ -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
diff --git a/docs/start/showcase.md b/docs/start/showcase.md
index 1e64dc7aa..3be0cb96b 100644
--- a/docs/start/showcase.md
+++ b/docs/start/showcase.md
@@ -11,9 +11,11 @@ Real projects from the community. Highlights from #showcase (Jan 2–5, 2026).
- **Grocery autopilot (Picnic)** — Skill built around an unofficial Picnic API client. Pulls order history, infers preferred brands, maps recipes to cart, completes order in minutes. https://github.com/timkrase/clawdis-picnic-skill
- **Grocery autopilot (Picnic, alt)** — Another Picnic-based skill built via the `picnic-api` package. https://github.com/MRVDH/picnic-api
- **German rail planning** — Go CLI for Deutsche Bahn; skill picks best connections given time windows and preferences. https://github.com/timkrase/dbrest-cli + https://github.com/timkrase/clawdis-skills/tree/main/db-bahn
+- **padel-cli** — Playtomic availability + booking CLI with a Clawdbot plugin output. github.com/joshp123/padel-cli
- **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. github.com/joshp123/xuezh
- **WhatsApp memory vault** — Ingests full exports, transcribes 1k+ voice notes, cross‑checks with git logs, outputs linked MD reports + ongoing indexing. (No link shared.)
- **Karakeep semantic search** — Sidecar adds vector search to Karakeep bookmarks (Qdrant + OpenAI/Ollama), includes Clawdis skill. https://github.com/jamesbrooksco/karakeep-semantic-search
- **Inside‑Out‑2 style memory** — Separate memory manager app turns session files into memories → beliefs → self model. (No link shared.)
@@ -26,11 +28,12 @@ Real projects from the community. Highlights from #showcase (Jan 2–5, 2026).
## Infrastructure & deployment
- **Home Assistant OS gateway add‑on** — Clawdbot gateway running on HA OS (Raspberry Pi), with SSH tunnel support + persistent state in /config. https://github.com/ngutman/clawdbot-ha-addon
- **Home Assistant skill** — Control/automate HA via ClawdHub. https://clawdhub.com/skills/homeassistant
-- **Nix packaging** — Batteries‑included nixified clawdis config. https://github.com/joshp123/nix-clawdis
+- **Nix packaging** — Batteries‑included nixified clawdbot config. https://github.com/clawdbot/nix-clawdbot
- **CalDAV skill** — khal/vdirsyncer based calendar skill. ClawdHub: caldav-calendar → https://clawdhub.com/skills/caldav-calendar
## Home + hardware
-- **Roborock integration** — Plugin for robot vacuum control. https://github.com/joshp123/gohome/tree/main/plugins/roborock
+- **gohome** — Nix-native home automation with Clawdbot as the interface, plus Grafana dashboards. github.com/joshp123/gohome
+- **Roborock integration** — Plugin for robot vacuum control. github.com/joshp123/gohome/tree/main/plugins/roborock
## Community builds (non‑Clawdis but made with/around it)
- **StarSwap marketplace** — Full astronomy gear marketplace. https://star-swap.com/
diff --git a/docs/tools/bash.md b/docs/tools/bash.md
index 75211c2d9..73106a1e5 100644
--- a/docs/tools/bash.md
+++ b/docs/tools/bash.md
@@ -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
diff --git a/docs/tools/index.md b/docs/tools/index.md
index c6db325cc..6e9d14daa 100644
--- a/docs/tools/index.md
+++ b/docs/tools/index.md
@@ -42,6 +42,7 @@ Core parameters:
Notes:
- Returns `status: "running"` with a `sessionId` when backgrounded.
- Use `process` to poll/log/write/kill/clear background sessions.
+- If `process` is disallowed, `bash` runs synchronously and ignores `yieldMs`/`background`.
### `process`
Manage background bash sessions.
@@ -52,6 +53,7 @@ Core actions:
Notes:
- `poll` returns new output and exit status when complete.
- `log` supports line-based `offset`/`limit` (omit `offset` to grab the last N lines).
+- `process` is scoped per agent; sessions from other agents are not visible.
### `browser`
Control the dedicated clawd browser.
@@ -157,13 +159,14 @@ Core parameters:
- `sessions_list`: `kinds?`, `limit?`, `activeMinutes?`, `messageLimit?` (0 = none)
- `sessions_history`: `sessionKey`, `limit?`, `includeTools?`
- `sessions_send`: `sessionKey`, `message`, `timeoutSeconds?` (0 = fire-and-forget)
-- `sessions_spawn`: `task`, `label?`, `model?`, `timeoutSeconds?`, `cleanup?`
+- `sessions_spawn`: `task`, `label?`, `model?`, `runTimeoutSeconds?`, `cleanup?`
Notes:
- `main` is the canonical direct-chat key; global/unknown are hidden.
- `messageLimit > 0` fetches last N messages per session (tool messages filtered).
- `sessions_send` waits for final completion when `timeoutSeconds > 0`.
- `sessions_spawn` starts a sub-agent run and posts an announce reply back to the requester chat.
+- `sessions_spawn` is non-blocking and returns `status: "accepted"` immediately.
- `sessions_send` runs a reply‑back ping‑pong (reply `REPLY_SKIP` to stop; max turns via `session.agentToAgent.maxPingPongTurns`, 0–5).
- After the ping‑pong, the target agent runs an **announce step**; reply `ANNOUNCE_SKIP` to suppress the announcement.
diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md
index 67633b9c5..58af62b71 100644
--- a/docs/tools/slash-commands.md
+++ b/docs/tools/slash-commands.md
@@ -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 `
+- `/model ` (or `/` from `agent.models.*.alias`)
- `/queue ` (plus options like `debounce:2s cap:25 drop:summarize`)
Text-only:
-- `/compact [instructions]`
+- `/compact [instructions]` (see [/concepts/compaction](/concepts/compaction))
## Surface notes
diff --git a/docs/tools/subagents.md b/docs/tools/subagents.md
index 68a88360d..c2d25e389 100644
--- a/docs/tools/subagents.md
+++ b/docs/tools/subagents.md
@@ -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::subagent:`) 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::subagent:`) 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.` (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.
diff --git a/package.json b/package.json
index 26b097c81..b21ad573b 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 53505f5f1..14f509a45 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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
diff --git a/showcase.md b/showcase.md
index 91ec938ce..7a57e6f49 100644
--- a/showcase.md
+++ b/showcase.md
@@ -2,6 +2,12 @@
Highlights from #showcase (Jan 2–5, 2026). Curated for “wow” factor + concrete links.
+## Clawdhub projects (formerly Clawdis)
+- **xuezh** — Chinese learning engine + Clawdbot skill for pronunciation feedback and study flows. github.com/joshp123/xuezh
+- **gohome** — Nix-native home automation with Clawdbot as the interface, plus Grafana dashboards. github.com/joshp123/gohome
+- **Roborock skill for GoHome** — Vacuum control plugin with gRPC actions + metrics. github.com/joshp123/gohome/tree/main/plugins/roborock
+- **padel-cli** — Playtomic availability + booking CLI with a Clawdbot plugin output. github.com/joshp123/padel-cli
+
## 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
diff --git a/skills/1password/SKILL.md b/skills/1password/SKILL.md
index 7aea6b8c1..7bac1be06 100644
--- a/skills/1password/SKILL.md
+++ b/skills/1password/SKILL.md
@@ -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.
diff --git a/src/agents/bash-process-registry.ts b/src/agents/bash-process-registry.ts
index d91081b6b..71c911376 100644
--- a/src/agents/bash-process-registry.ts
+++ b/src/agents/bash-process-registry.ts
@@ -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,
diff --git a/src/agents/bash-tools.test.ts b/src/agents/bash-tools.test.ts
index 7276803bb..9214ab2c7 100644
--- a/src/agents/bash-tools.test.ts
+++ b/src/agents/bash-tools.test.ts
@@ -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");
+ });
});
diff --git a/src/agents/bash-tools.ts b/src/agents/bash-tools.ts
index c380710a3..bb4aff4c5 100644
--- a/src/agents/bash-tools.ts
+++ b/src/agents/bash-tools.ts
@@ -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[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 = (
+ values: T,
+ options?: { description?: string },
) =>
- Type.Union(
- values.map((value) => Type.Literal(value)) as [
- ReturnType,
- ...ReturnType[],
- ],
- options,
- );
+ Type.Unsafe({
+ 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((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: [
diff --git a/src/agents/clawdbot-tools.subagents.test.ts b/src/agents/clawdbot-tools.subagents.test.ts
index d8be2d249..0df0a0abd 100644
--- a/src/agents/clawdbot-tools.subagents.test.ts
+++ b/src/agents/clawdbot-tools.subagents.test.ts
@@ -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();
let sendParams: { to?: string; provider?: string; message?: string } = {};
let deletedKey: string | undefined;
+ let childRunId: string | undefined;
+ let childSessionKey: string | undefined;
+ const sessionLastAssistantText = new Map();
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();
let sendParams: { to?: string; provider?: string; message?: string } = {};
+ let childRunId: string | undefined;
+ let childSessionKey: string | undefined;
+ const sessionLastAssistantText = new Map();
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();
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();
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);
+ });
});
diff --git a/src/agents/pi-embedded-helpers.test.ts b/src/agents/pi-embedded-helpers.test.ts
index c36664dba..6d95b50f1 100644
--- a/src/agents/pi-embedded-helpers.test.ts
+++ b/src/agents/pi-embedded-helpers.test.ts
@@ -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;
+ 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);
+ });
+});
diff --git a/src/agents/pi-embedded-helpers.ts b/src/agents/pi-embedded-helpers.ts
index 750c9504b..ad2ac3704 100644
--- a/src/agents/pi-embedded-helpers.ts
+++ b/src/agents/pi-embedded-helpers.ts
@@ -104,6 +104,40 @@ export async function sanitizeSessionMessagesImages(
return out;
}
+const GOOGLE_TURN_ORDER_BOOTSTRAP_TEXT = "(session bootstrap)";
+
+export function isGoogleModelApi(api?: string | null): boolean {
+ return api === "google-gemini-cli" || api === "google-generative-ai";
+}
+
+export function sanitizeGoogleTurnOrdering(
+ messages: AgentMessage[],
+): AgentMessage[] {
+ const first = messages[0] as
+ | { role?: unknown; content?: unknown }
+ | undefined;
+ const role = first?.role;
+ const content = first?.content;
+ if (
+ role === "user" &&
+ typeof content === "string" &&
+ content.trim() === GOOGLE_TURN_ORDER_BOOTSTRAP_TEXT
+ ) {
+ return messages;
+ }
+ if (role !== "assistant") return messages;
+
+ // Cloud Code Assist rejects histories that begin with a model turn (tool call or text).
+ // Prepend a tiny synthetic user turn so the rest of the transcript can be used.
+ const bootstrap: AgentMessage = {
+ role: "user",
+ content: GOOGLE_TURN_ORDER_BOOTSTRAP_TEXT,
+ timestamp: Date.now(),
+ } as AgentMessage;
+
+ return [bootstrap, ...messages];
+}
+
export function buildBootstrapContextFiles(
files: WorkspaceBootstrapFile[],
): EmbeddedContextFile[] {
@@ -126,6 +160,18 @@ export function buildBootstrapContextFiles(
return result;
}
+export function isContextOverflowError(errorMessage?: string): boolean {
+ if (!errorMessage) return false;
+ const lower = errorMessage.toLowerCase();
+ return (
+ lower.includes("request_too_large") ||
+ lower.includes("request exceeds the maximum size") ||
+ lower.includes("context length exceeded") ||
+ lower.includes("maximum context length") ||
+ (lower.includes("413") && lower.includes("too large"))
+ );
+}
+
export function formatAssistantErrorText(
msg: AssistantMessage,
): string | undefined {
@@ -133,6 +179,14 @@ export function formatAssistantErrorText(
const raw = (msg.errorMessage ?? "").trim();
if (!raw) return "LLM request failed with an unknown error.";
+ // Check for context overflow (413) errors
+ if (isContextOverflowError(raw)) {
+ return (
+ "Context overflow: the conversation history is too large. " +
+ "Use /new or /reset to start a fresh session."
+ );
+ }
+
const invalidRequest = raw.match(
/"type":"invalid_request_error".*?"message":"([^"]+)"/,
);
@@ -218,3 +272,77 @@ export function pickFallbackThinkingLevel(params: {
}
return undefined;
}
+
+/**
+ * Validates and fixes conversation turn sequences for Gemini API.
+ * Gemini requires strict alternating user→assistant→tool→user pattern.
+ * This function:
+ * 1. Detects consecutive messages from the same role
+ * 2. Merges consecutive assistant messages together
+ * 3. Preserves metadata (usage, stopReason, etc.)
+ *
+ * This prevents the "function call turn comes immediately after a user turn or after a function response turn" error.
+ */
+export function validateGeminiTurns(messages: AgentMessage[]): AgentMessage[] {
+ if (!Array.isArray(messages) || messages.length === 0) {
+ return messages;
+ }
+
+ const result: AgentMessage[] = [];
+ let lastRole: string | undefined;
+
+ for (const msg of messages) {
+ if (!msg || typeof msg !== "object") {
+ result.push(msg);
+ continue;
+ }
+
+ const msgRole = (msg as { role?: unknown }).role as string | undefined;
+ if (!msgRole) {
+ result.push(msg);
+ continue;
+ }
+
+ // Check if this message has the same role as the last one
+ if (msgRole === lastRole && lastRole === "assistant") {
+ // Merge consecutive assistant messages
+ const lastMsg = result[result.length - 1];
+ const currentMsg = msg as Extract;
+
+ 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 = {
+ ...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;
+}
diff --git a/src/agents/pi-embedded-runner.test.ts b/src/agents/pi-embedded-runner.test.ts
index ac5b75a76..e2fc92541 100644
--- a/src/agents/pi-embedded-runner.test.ts
+++ b/src/agents/pi-embedded-runner.test.ts
@@ -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();
+ });
+});
diff --git a/src/agents/pi-embedded-runner.ts b/src/agents/pi-embedded-runner.ts
index d153a5802..cf428d64f 100644
--- a/src/agents/pi-embedded-runner.ts
+++ b/src/agents/pi-embedded-runner.ts
@@ -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 | 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 | 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 {
+ 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();
type EmbeddedRunWaiter = {
@@ -163,6 +328,66 @@ type EmbeddedRunWaiter = {
};
const EMBEDDED_RUN_WAITERS = new Map>();
+// ============================================================================
+// SessionManager Pre-warming Cache
+// ============================================================================
+
+type SessionManagerCacheEntry = {
+ sessionFile: string;
+ loadedAt: number;
+};
+
+const SESSION_MANAGER_CACHE = new Map();
+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 {
+ 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;
+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>["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
+ >["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;
+ 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)) &&
diff --git a/src/agents/pi-extensions/context-pruning.test.ts b/src/agents/pi-extensions/context-pruning.test.ts
new file mode 100644
index 000000000..3d28c519e
--- /dev/null
+++ b/src/agents/pi-extensions/context-pruning.test.ts
@@ -0,0 +1,447 @@
+import type { AgentMessage } from "@mariozechner/pi-agent-core";
+import type {
+ ExtensionAPI,
+ ExtensionContext,
+} from "@mariozechner/pi-coding-agent";
+import { describe, expect, it } from "vitest";
+
+import { setContextPruningRuntime } from "./context-pruning/runtime.js";
+
+import {
+ computeEffectiveSettings,
+ default as contextPruningExtension,
+ DEFAULT_CONTEXT_PRUNING_SETTINGS,
+ pruneContextMessages,
+} from "./context-pruning.js";
+
+function toolText(msg: AgentMessage): string {
+ if (msg.role !== "toolResult") throw new Error("expected toolResult");
+ const first = msg.content.find((b) => b.type === "text");
+ if (!first || first.type !== "text") return "";
+ return first.text;
+}
+
+function findToolResult(
+ messages: AgentMessage[],
+ toolCallId: string,
+): AgentMessage {
+ const msg = messages.find(
+ (m) => m.role === "toolResult" && m.toolCallId === toolCallId,
+ );
+ if (!msg) throw new Error(`missing toolResult: ${toolCallId}`);
+ return msg;
+}
+
+function makeToolResult(params: {
+ toolCallId: string;
+ toolName: string;
+ text: string;
+}): AgentMessage {
+ return {
+ role: "toolResult",
+ toolCallId: params.toolCallId,
+ toolName: params.toolName,
+ content: [{ type: "text", text: params.text }],
+ isError: false,
+ timestamp: Date.now(),
+ };
+}
+
+function makeImageToolResult(params: {
+ toolCallId: string;
+ toolName: string;
+ text: string;
+}): AgentMessage {
+ return {
+ role: "toolResult",
+ toolCallId: params.toolCallId,
+ toolName: params.toolName,
+ content: [
+ { type: "image", data: "AA==", mimeType: "image/png" },
+ { type: "text", text: params.text },
+ ],
+ isError: false,
+ timestamp: Date.now(),
+ };
+}
+
+function makeAssistant(text: string): AgentMessage {
+ return {
+ role: "assistant",
+ content: [{ type: "text", text }],
+ api: "openai-responses",
+ provider: "openai",
+ model: "fake",
+ usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, total: 2 },
+ stopReason: "stop",
+ timestamp: Date.now(),
+ };
+}
+
+function makeUser(text: string): AgentMessage {
+ return { role: "user", content: text, timestamp: Date.now() };
+}
+
+describe("context-pruning", () => {
+ it("mode off disables pruning", () => {
+ expect(computeEffectiveSettings({ mode: "off" })).toBeNull();
+ expect(computeEffectiveSettings({})).toBeNull();
+ });
+
+ it("does not touch tool results after the last N assistants", () => {
+ const messages: AgentMessage[] = [
+ makeUser("u1"),
+ makeAssistant("a1"),
+ makeToolResult({
+ toolCallId: "t1",
+ toolName: "bash",
+ text: "x".repeat(20_000),
+ }),
+ makeUser("u2"),
+ makeAssistant("a2"),
+ makeToolResult({
+ toolCallId: "t2",
+ toolName: "bash",
+ text: "y".repeat(20_000),
+ }),
+ makeUser("u3"),
+ makeAssistant("a3"),
+ makeToolResult({
+ toolCallId: "t3",
+ toolName: "bash",
+ text: "z".repeat(20_000),
+ }),
+ makeUser("u4"),
+ makeAssistant("a4"),
+ makeToolResult({
+ toolCallId: "t4",
+ toolName: "bash",
+ text: "w".repeat(20_000),
+ }),
+ ];
+
+ const settings = {
+ ...DEFAULT_CONTEXT_PRUNING_SETTINGS,
+ keepLastAssistants: 3,
+ softTrimRatio: 0.0,
+ hardClearRatio: 0.0,
+ minPrunableToolChars: 0,
+ hardClear: { enabled: true, placeholder: "[cleared]" },
+ softTrim: { maxChars: 10, headChars: 3, tailChars: 3 },
+ };
+
+ const ctx = {
+ model: { contextWindow: 1000 },
+ } as unknown as ExtensionContext;
+
+ const next = pruneContextMessages({ messages, settings, ctx });
+
+ expect(toolText(findToolResult(next, "t2"))).toContain("y".repeat(20_000));
+ expect(toolText(findToolResult(next, "t3"))).toContain("z".repeat(20_000));
+ expect(toolText(findToolResult(next, "t4"))).toContain("w".repeat(20_000));
+ expect(toolText(findToolResult(next, "t1"))).toBe("[cleared]");
+ });
+
+ it("never prunes tool results before the first user message", () => {
+ const settings = computeEffectiveSettings({
+ mode: "aggressive",
+ keepLastAssistants: 0,
+ hardClear: { placeholder: "[cleared]" },
+ });
+ if (!settings) throw new Error("expected settings");
+
+ const messages: AgentMessage[] = [
+ makeAssistant("bootstrap tool calls"),
+ makeToolResult({
+ toolCallId: "t0",
+ toolName: "read",
+ text: "x".repeat(20_000),
+ }),
+ makeAssistant("greeting"),
+ makeUser("u1"),
+ makeToolResult({
+ toolCallId: "t1",
+ toolName: "bash",
+ text: "y".repeat(20_000),
+ }),
+ ];
+
+ const next = pruneContextMessages({
+ messages,
+ settings,
+ ctx: { model: { contextWindow: 1000 } } as unknown as ExtensionContext,
+ isToolPrunable: () => true,
+ contextWindowTokensOverride: 1000,
+ });
+
+ expect(toolText(findToolResult(next, "t0"))).toBe("x".repeat(20_000));
+ expect(toolText(findToolResult(next, "t1"))).toBe("[cleared]");
+ });
+
+ it("mode aggressive clears eligible tool results before cutoff", () => {
+ const messages: AgentMessage[] = [
+ makeUser("u1"),
+ makeAssistant("a1"),
+ makeToolResult({
+ toolCallId: "t1",
+ toolName: "bash",
+ text: "x".repeat(20_000),
+ }),
+ makeToolResult({
+ toolCallId: "t2",
+ toolName: "bash",
+ text: "y".repeat(20_000),
+ }),
+ makeUser("u2"),
+ makeAssistant("a2"),
+ makeToolResult({
+ toolCallId: "t3",
+ toolName: "bash",
+ text: "z".repeat(20_000),
+ }),
+ ];
+
+ const settings = {
+ ...DEFAULT_CONTEXT_PRUNING_SETTINGS,
+ mode: "aggressive",
+ keepLastAssistants: 1,
+ hardClear: { enabled: false, placeholder: "[cleared]" },
+ };
+
+ const ctx = {
+ model: { contextWindow: 1000 },
+ } as unknown as ExtensionContext;
+ const next = pruneContextMessages({ messages, settings, ctx });
+
+ expect(toolText(findToolResult(next, "t1"))).toBe("[cleared]");
+ expect(toolText(findToolResult(next, "t2"))).toBe("[cleared]");
+ // Tool results after the last assistant are protected.
+ expect(toolText(findToolResult(next, "t3"))).toContain("z".repeat(20_000));
+ });
+
+ it("uses contextWindow override when ctx.model is missing", () => {
+ const messages: AgentMessage[] = [
+ makeUser("u1"),
+ makeAssistant("a1"),
+ makeToolResult({
+ toolCallId: "t1",
+ toolName: "bash",
+ text: "x".repeat(20_000),
+ }),
+ makeAssistant("a2"),
+ ];
+
+ const settings = {
+ ...DEFAULT_CONTEXT_PRUNING_SETTINGS,
+ keepLastAssistants: 0,
+ softTrimRatio: 0,
+ hardClearRatio: 0,
+ minPrunableToolChars: 0,
+ hardClear: { enabled: true, placeholder: "[cleared]" },
+ softTrim: { maxChars: 10, headChars: 3, tailChars: 3 },
+ };
+
+ const next = pruneContextMessages({
+ messages,
+ settings,
+ ctx: { model: undefined } as unknown as ExtensionContext,
+ contextWindowTokensOverride: 1000,
+ });
+
+ expect(toolText(findToolResult(next, "t1"))).toBe("[cleared]");
+ });
+
+ it("reads per-session settings from registry", async () => {
+ const sessionManager = {};
+
+ setContextPruningRuntime(sessionManager, {
+ settings: {
+ ...DEFAULT_CONTEXT_PRUNING_SETTINGS,
+ keepLastAssistants: 0,
+ softTrimRatio: 0,
+ hardClearRatio: 0,
+ minPrunableToolChars: 0,
+ hardClear: { enabled: true, placeholder: "[cleared]" },
+ softTrim: { maxChars: 10, headChars: 3, tailChars: 3 },
+ },
+ contextWindowTokens: 1000,
+ isToolPrunable: () => true,
+ });
+
+ const messages: AgentMessage[] = [
+ makeUser("u1"),
+ makeAssistant("a1"),
+ makeToolResult({
+ toolCallId: "t1",
+ toolName: "bash",
+ text: "x".repeat(20_000),
+ }),
+ makeAssistant("a2"),
+ ];
+
+ let handler:
+ | ((
+ event: { messages: AgentMessage[] },
+ ctx: ExtensionContext,
+ ) => { messages: AgentMessage[] } | undefined)
+ | undefined;
+
+ const api = {
+ on: (name: string, fn: unknown) => {
+ if (name === "context") {
+ handler = fn as typeof handler;
+ }
+ },
+ appendEntry: (_type: string, _data?: unknown) => {},
+ } as unknown as ExtensionAPI;
+
+ contextPruningExtension(api);
+
+ if (!handler) throw new Error("missing context handler");
+
+ const result = handler({ messages }, {
+ model: undefined,
+ sessionManager,
+ } as unknown as ExtensionContext);
+
+ if (!result) throw new Error("expected handler to return messages");
+ expect(toolText(findToolResult(result.messages, "t1"))).toBe("[cleared]");
+ });
+
+ it("respects tools allow/deny (deny wins; wildcards supported)", () => {
+ const messages: AgentMessage[] = [
+ makeUser("u1"),
+ makeToolResult({
+ toolCallId: "t1",
+ toolName: "bash",
+ text: "x".repeat(20_000),
+ }),
+ makeToolResult({
+ toolCallId: "t2",
+ toolName: "browser",
+ text: "y".repeat(20_000),
+ }),
+ ];
+
+ const settings = {
+ ...DEFAULT_CONTEXT_PRUNING_SETTINGS,
+ keepLastAssistants: 0,
+ softTrimRatio: 0.0,
+ hardClearRatio: 0.0,
+ minPrunableToolChars: 0,
+ tools: { allow: ["ba*"], deny: ["bash"] },
+ hardClear: { enabled: true, placeholder: "[cleared]" },
+ softTrim: { maxChars: 10, headChars: 3, tailChars: 3 },
+ };
+
+ const ctx = {
+ model: { contextWindow: 1000 },
+ } as unknown as ExtensionContext;
+ const next = pruneContextMessages({ messages, settings, ctx });
+
+ // Deny wins => bash is not pruned, even though allow matches.
+ expect(toolText(findToolResult(next, "t1"))).toContain("x".repeat(20_000));
+ // allow is non-empty and browser is not allowed => never pruned.
+ expect(toolText(findToolResult(next, "t2"))).toContain("y".repeat(20_000));
+ });
+
+ it("skips tool results that contain images (no soft trim, no hard clear)", () => {
+ const messages: AgentMessage[] = [
+ makeUser("u1"),
+ makeImageToolResult({
+ toolCallId: "t1",
+ toolName: "bash",
+ text: "x".repeat(20_000),
+ }),
+ ];
+
+ const settings = {
+ ...DEFAULT_CONTEXT_PRUNING_SETTINGS,
+ keepLastAssistants: 0,
+ softTrimRatio: 0.0,
+ hardClearRatio: 0.0,
+ minPrunableToolChars: 0,
+ hardClear: { enabled: true, placeholder: "[cleared]" },
+ softTrim: { maxChars: 10, headChars: 3, tailChars: 3 },
+ };
+
+ const ctx = {
+ model: { contextWindow: 1000 },
+ } as unknown as ExtensionContext;
+ const next = pruneContextMessages({ messages, settings, ctx });
+
+ const tool = findToolResult(next, "t1");
+ if (!tool || tool.role !== "toolResult") {
+ throw new Error("unexpected pruned message list shape");
+ }
+ expect(tool.content.some((b) => b.type === "image")).toBe(true);
+ expect(toolText(tool)).toContain("x".repeat(20_000));
+ });
+
+ it("soft-trims across block boundaries", () => {
+ const messages: AgentMessage[] = [
+ makeUser("u1"),
+ {
+ role: "toolResult",
+ toolCallId: "t1",
+ toolName: "bash",
+ content: [
+ { type: "text", text: "AAAAA" },
+ { type: "text", text: "BBBBB" },
+ ],
+ isError: false,
+ timestamp: Date.now(),
+ } as unknown as AgentMessage,
+ ];
+
+ const settings = {
+ ...DEFAULT_CONTEXT_PRUNING_SETTINGS,
+ keepLastAssistants: 0,
+ softTrimRatio: 0.0,
+ hardClearRatio: 10.0,
+ softTrim: { maxChars: 5, headChars: 7, tailChars: 3 },
+ };
+
+ const ctx = {
+ model: { contextWindow: 1000 },
+ } as unknown as ExtensionContext;
+ const next = pruneContextMessages({ messages, settings, ctx });
+
+ const text = toolText(findToolResult(next, "t1"));
+ expect(text).toContain("AAAAA\nB");
+ expect(text).toContain("BBB");
+ expect(text).toContain("[Tool result trimmed:");
+ });
+
+ it("soft-trims oversized tool results and preserves head/tail with a note", () => {
+ const messages: AgentMessage[] = [
+ makeUser("u1"),
+ makeToolResult({
+ toolCallId: "t1",
+ toolName: "bash",
+ text: "abcdefghij".repeat(1000),
+ }),
+ ];
+
+ const settings = {
+ ...DEFAULT_CONTEXT_PRUNING_SETTINGS,
+ keepLastAssistants: 0,
+ softTrimRatio: 0.0,
+ hardClearRatio: 10.0,
+ minPrunableToolChars: 0,
+ hardClear: { enabled: true, placeholder: "[cleared]" },
+ softTrim: { maxChars: 10, headChars: 6, tailChars: 6 },
+ };
+
+ const ctx = {
+ model: { contextWindow: 1000 },
+ } as unknown as ExtensionContext;
+ const next = pruneContextMessages({ messages, settings, ctx });
+
+ const tool = findToolResult(next, "t1");
+ const text = toolText(tool);
+ expect(text).toContain("abcdef");
+ expect(text).toContain("efghij");
+ expect(text).toContain("[Tool result trimmed:");
+ });
+});
diff --git a/src/agents/pi-extensions/context-pruning.ts b/src/agents/pi-extensions/context-pruning.ts
new file mode 100644
index 000000000..b80addb9d
--- /dev/null
+++ b/src/agents/pi-extensions/context-pruning.ts
@@ -0,0 +1,19 @@
+/**
+ * Opt-in context pruning (“microcompact”-style) for Pi sessions.
+ *
+ * This only affects the in-memory context for the current request; it does not rewrite session
+ * history persisted on disk.
+ */
+
+export { default } from "./context-pruning/extension.js";
+
+export { pruneContextMessages } from "./context-pruning/pruner.js";
+export type {
+ ContextPruningConfig,
+ ContextPruningToolMatch,
+ EffectiveContextPruningSettings,
+} from "./context-pruning/settings.js";
+export {
+ computeEffectiveSettings,
+ DEFAULT_CONTEXT_PRUNING_SETTINGS,
+} from "./context-pruning/settings.js";
diff --git a/src/agents/pi-extensions/context-pruning/extension.ts b/src/agents/pi-extensions/context-pruning/extension.ts
new file mode 100644
index 000000000..13b9a8d4b
--- /dev/null
+++ b/src/agents/pi-extensions/context-pruning/extension.ts
@@ -0,0 +1,27 @@
+import type { AgentMessage } from "@mariozechner/pi-agent-core";
+import type {
+ ContextEvent,
+ ExtensionAPI,
+ ExtensionContext,
+} from "@mariozechner/pi-coding-agent";
+
+import { pruneContextMessages } from "./pruner.js";
+import { getContextPruningRuntime } from "./runtime.js";
+
+export default function contextPruningExtension(api: ExtensionAPI): void {
+ api.on("context", (event: ContextEvent, ctx: ExtensionContext) => {
+ const runtime = getContextPruningRuntime(ctx.sessionManager);
+ if (!runtime) return undefined;
+
+ const next = pruneContextMessages({
+ messages: event.messages as AgentMessage[],
+ settings: runtime.settings,
+ ctx,
+ isToolPrunable: runtime.isToolPrunable,
+ contextWindowTokensOverride: runtime.contextWindowTokens ?? undefined,
+ });
+
+ if (next === event.messages) return undefined;
+ return { messages: next };
+ });
+}
diff --git a/src/agents/pi-extensions/context-pruning/pruner.ts b/src/agents/pi-extensions/context-pruning/pruner.ts
new file mode 100644
index 000000000..589cf1bb4
--- /dev/null
+++ b/src/agents/pi-extensions/context-pruning/pruner.ts
@@ -0,0 +1,324 @@
+import type { AgentMessage } from "@mariozechner/pi-agent-core";
+import type {
+ ImageContent,
+ TextContent,
+ ToolResultMessage,
+} from "@mariozechner/pi-ai";
+import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
+
+import type { EffectiveContextPruningSettings } from "./settings.js";
+import { makeToolPrunablePredicate } from "./tools.js";
+
+const CHARS_PER_TOKEN_ESTIMATE = 4;
+// We currently skip pruning tool results that contain images. Still, we count them (approx.) so
+// we start trimming prunable tool results earlier when image-heavy context is consuming the window.
+const IMAGE_CHAR_ESTIMATE = 8_000;
+
+function asText(text: string): TextContent {
+ return { type: "text", text };
+}
+
+function collectTextSegments(
+ content: ReadonlyArray,
+): string[] {
+ const parts: string[] = [];
+ for (const block of content) {
+ if (block.type === "text") parts.push(block.text);
+ }
+ return parts;
+}
+
+function estimateJoinedTextLength(parts: string[]): number {
+ if (parts.length === 0) return 0;
+ let len = 0;
+ for (const p of parts) len += p.length;
+ // Joined with "\n" separators between blocks.
+ len += Math.max(0, parts.length - 1);
+ return len;
+}
+
+function takeHeadFromJoinedText(parts: string[], maxChars: number): string {
+ if (maxChars <= 0 || parts.length === 0) return "";
+ let remaining = maxChars;
+ let out = "";
+ for (let i = 0; i < parts.length && remaining > 0; i++) {
+ if (i > 0) {
+ out += "\n";
+ remaining -= 1;
+ if (remaining <= 0) break;
+ }
+ const p = parts[i];
+ if (p.length <= remaining) {
+ out += p;
+ remaining -= p.length;
+ } else {
+ out += p.slice(0, remaining);
+ remaining = 0;
+ }
+ }
+ return out;
+}
+
+function takeTailFromJoinedText(parts: string[], maxChars: number): string {
+ if (maxChars <= 0 || parts.length === 0) return "";
+ let remaining = maxChars;
+ const out: string[] = [];
+ for (let i = parts.length - 1; i >= 0 && remaining > 0; i--) {
+ const p = parts[i];
+ if (p.length <= remaining) {
+ out.push(p);
+ remaining -= p.length;
+ } else {
+ out.push(p.slice(p.length - remaining));
+ remaining = 0;
+ break;
+ }
+ if (remaining > 0 && i > 0) {
+ out.push("\n");
+ remaining -= 1;
+ }
+ }
+ out.reverse();
+ return out.join("");
+}
+
+function hasImageBlocks(
+ content: ReadonlyArray,
+): boolean {
+ for (const block of content) {
+ if (block.type === "image") return true;
+ }
+ return false;
+}
+
+function estimateMessageChars(message: AgentMessage): number {
+ if (message.role === "user") {
+ const content = message.content;
+ if (typeof content === "string") return content.length;
+ let chars = 0;
+ for (const b of content) {
+ if (b.type === "text") chars += b.text.length;
+ if (b.type === "image") chars += IMAGE_CHAR_ESTIMATE;
+ }
+ return chars;
+ }
+
+ if (message.role === "assistant") {
+ let chars = 0;
+ for (const b of message.content) {
+ if (b.type === "text") chars += b.text.length;
+ if (b.type === "thinking") chars += b.thinking.length;
+ if (b.type === "toolCall") {
+ try {
+ chars += JSON.stringify(b.arguments ?? {}).length;
+ } catch {
+ chars += 128;
+ }
+ }
+ }
+ return chars;
+ }
+
+ if (message.role === "toolResult") {
+ let chars = 0;
+ for (const b of message.content) {
+ if (b.type === "text") chars += b.text.length;
+ if (b.type === "image") chars += IMAGE_CHAR_ESTIMATE;
+ }
+ return chars;
+ }
+
+ return 256;
+}
+
+function estimateContextChars(messages: AgentMessage[]): number {
+ return messages.reduce((sum, m) => sum + estimateMessageChars(m), 0);
+}
+
+function findAssistantCutoffIndex(
+ messages: AgentMessage[],
+ keepLastAssistants: number,
+): number | null {
+ // keepLastAssistants <= 0 => everything is potentially prunable.
+ if (keepLastAssistants <= 0) return messages.length;
+
+ let remaining = keepLastAssistants;
+ for (let i = messages.length - 1; i >= 0; i--) {
+ if (messages[i]?.role !== "assistant") continue;
+ remaining--;
+ if (remaining === 0) return i;
+ }
+
+ // Not enough assistant messages to establish a protected tail.
+ return null;
+}
+
+function findFirstUserIndex(messages: AgentMessage[]): number | null {
+ for (let i = 0; i < messages.length; i++) {
+ if (messages[i]?.role === "user") return i;
+ }
+ return null;
+}
+
+function softTrimToolResultMessage(params: {
+ msg: ToolResultMessage;
+ settings: EffectiveContextPruningSettings;
+}): ToolResultMessage | null {
+ const { msg, settings } = params;
+ // Ignore image tool results for now: these are often directly relevant and hard to partially prune safely.
+ if (hasImageBlocks(msg.content)) return null;
+
+ const parts = collectTextSegments(msg.content);
+ const rawLen = estimateJoinedTextLength(parts);
+ if (rawLen <= settings.softTrim.maxChars) return null;
+
+ const headChars = Math.max(0, settings.softTrim.headChars);
+ const tailChars = Math.max(0, settings.softTrim.tailChars);
+ if (headChars + tailChars >= rawLen) return null;
+
+ const head = takeHeadFromJoinedText(parts, headChars);
+ const tail = takeTailFromJoinedText(parts, tailChars);
+ const trimmed = `${head}
+...
+${tail}`;
+
+ const note = `
+
+[Tool result trimmed: kept first ${headChars} chars and last ${tailChars} chars of ${rawLen} chars.]`;
+
+ return { ...msg, content: [asText(trimmed + note)] };
+}
+
+export function pruneContextMessages(params: {
+ messages: AgentMessage[];
+ settings: EffectiveContextPruningSettings;
+ ctx: Pick;
+ isToolPrunable?: (toolName: string) => boolean;
+ contextWindowTokensOverride?: number;
+}): AgentMessage[] {
+ const { messages, settings, ctx } = params;
+ const contextWindowTokens =
+ typeof params.contextWindowTokensOverride === "number" &&
+ Number.isFinite(params.contextWindowTokensOverride) &&
+ params.contextWindowTokensOverride > 0
+ ? params.contextWindowTokensOverride
+ : ctx.model?.contextWindow;
+ if (!contextWindowTokens || contextWindowTokens <= 0) return messages;
+
+ const charWindow = contextWindowTokens * CHARS_PER_TOKEN_ESTIMATE;
+ if (charWindow <= 0) return messages;
+
+ const cutoffIndex = findAssistantCutoffIndex(
+ messages,
+ settings.keepLastAssistants,
+ );
+ if (cutoffIndex === null) return messages;
+
+ // Bootstrap safety: never prune anything before the first user message. This protects initial
+ // "identity" reads (SOUL.md, USER.md, etc.) which typically happen before the first inbound user
+ // message exists in the session transcript.
+ const firstUserIndex = findFirstUserIndex(messages);
+ const pruneStartIndex =
+ firstUserIndex === null ? messages.length : firstUserIndex;
+
+ const isToolPrunable =
+ params.isToolPrunable ?? makeToolPrunablePredicate(settings.tools);
+
+ if (settings.mode === "aggressive") {
+ let next: AgentMessage[] | null = null;
+
+ for (let i = pruneStartIndex; i < cutoffIndex; i++) {
+ const msg = messages[i];
+ if (!msg || msg.role !== "toolResult") continue;
+ if (!isToolPrunable(msg.toolName)) continue;
+ if (hasImageBlocks(msg.content)) {
+ continue;
+ }
+
+ const alreadyCleared =
+ msg.content.length === 1 &&
+ msg.content[0]?.type === "text" &&
+ msg.content[0].text === settings.hardClear.placeholder;
+ if (alreadyCleared) continue;
+
+ const cleared: ToolResultMessage = {
+ ...msg,
+ content: [asText(settings.hardClear.placeholder)],
+ };
+ if (!next) next = messages.slice();
+ next[i] = cleared as unknown as AgentMessage;
+ }
+
+ return next ?? messages;
+ }
+
+ const totalCharsBefore = estimateContextChars(messages);
+ let totalChars = totalCharsBefore;
+ let ratio = totalChars / charWindow;
+ if (ratio < settings.softTrimRatio) {
+ return messages;
+ }
+
+ const prunableToolIndexes: number[] = [];
+ let next: AgentMessage[] | null = null;
+
+ for (let i = pruneStartIndex; i < cutoffIndex; i++) {
+ const msg = messages[i];
+ if (!msg || msg.role !== "toolResult") continue;
+ if (!isToolPrunable(msg.toolName)) continue;
+ if (hasImageBlocks(msg.content)) {
+ continue;
+ }
+ prunableToolIndexes.push(i);
+
+ const updated = softTrimToolResultMessage({
+ msg: msg as unknown as ToolResultMessage,
+ settings,
+ });
+ if (!updated) continue;
+
+ const beforeChars = estimateMessageChars(msg);
+ const afterChars = estimateMessageChars(updated as unknown as AgentMessage);
+ totalChars += afterChars - beforeChars;
+ if (!next) next = messages.slice();
+ next[i] = updated as unknown as AgentMessage;
+ }
+
+ const outputAfterSoftTrim = next ?? messages;
+ ratio = totalChars / charWindow;
+ if (ratio < settings.hardClearRatio) {
+ return outputAfterSoftTrim;
+ }
+ if (!settings.hardClear.enabled) {
+ return outputAfterSoftTrim;
+ }
+
+ let prunableToolChars = 0;
+ for (const i of prunableToolIndexes) {
+ const msg = outputAfterSoftTrim[i];
+ if (!msg || msg.role !== "toolResult") continue;
+ prunableToolChars += estimateMessageChars(msg);
+ }
+ if (prunableToolChars < settings.minPrunableToolChars) {
+ return outputAfterSoftTrim;
+ }
+
+ for (const i of prunableToolIndexes) {
+ if (ratio < settings.hardClearRatio) break;
+ const msg = (next ?? messages)[i];
+ if (!msg || msg.role !== "toolResult") continue;
+
+ const beforeChars = estimateMessageChars(msg);
+ const cleared: ToolResultMessage = {
+ ...msg,
+ content: [asText(settings.hardClear.placeholder)],
+ };
+ if (!next) next = messages.slice();
+ next[i] = cleared as unknown as AgentMessage;
+ const afterChars = estimateMessageChars(cleared as unknown as AgentMessage);
+ totalChars += afterChars - beforeChars;
+ ratio = totalChars / charWindow;
+ }
+
+ return next ?? messages;
+}
diff --git a/src/agents/pi-extensions/context-pruning/runtime.ts b/src/agents/pi-extensions/context-pruning/runtime.ts
new file mode 100644
index 000000000..b497e6383
--- /dev/null
+++ b/src/agents/pi-extensions/context-pruning/runtime.ts
@@ -0,0 +1,39 @@
+import type { EffectiveContextPruningSettings } from "./settings.js";
+
+export type ContextPruningRuntimeValue = {
+ settings: EffectiveContextPruningSettings;
+ contextWindowTokens?: number | null;
+ isToolPrunable: (toolName: string) => boolean;
+};
+
+// Session-scoped runtime registry keyed by object identity.
+// Important: this relies on Pi passing the same SessionManager object instance into
+// ExtensionContext (ctx.sessionManager) that we used when calling setContextPruningRuntime.
+const REGISTRY = new WeakMap