Compare commits

..
Author SHA1 Message Date
Peter Steinberger 6b3f9a5934 docs: note android sms capability 2026-01-04 13:57:41 +01:00
Peter Steinberger 0372457ba8 fix(android): refresh hello on sms permission grant 2026-01-04 13:56:20 +01:00
Peter Steinberger f28a03c407 fix(android): add sms permission flow and tests 2026-01-04 13:27:30 +01:00
Vasanth Rao Naik Sabavat f5abc8e9c9 feat(android): add SMS sending capability to Android node
Add sms.send command to allow sending text messages via the paired Android device.

Changes:
- Add SmsManager class to handle SMS sending via Android SmsManager API
- Add ClawdisSmsCommand enum and Sms capability to protocol constants
- Wire sms.send command into NodeRuntime invoke handler
- Add SEND_SMS permission to AndroidManifest.xml
- Advertise sms capability when SEND_SMS permission is granted

The SMS capability is only advertised when the user has granted SEND_SMS
permission. Messages longer than 160 chars are automatically split into
multipart messages.
2026-01-03 22:16:46 -08:00
Peter Steinberger 4b3ca29404 build: add homebrew to sandbox image 2026-01-04 06:12:06 +00:00
Peter Steinberger 259b14d66a chore: refresh protocol models 2026-01-04 07:07:21 +01:00
Peter Steinberger c9504a6f20 refactor: split config module 2026-01-04 07:05:17 +01:00
Peter Steinberger 5e36e2c3f3 fix: allow elevated via discord username 2026-01-04 05:47:28 +00:00
Peter Steinberger d2da305190 feat: fallback elevated allowlist to discord dms 2026-01-04 05:31:00 +00:00
Peter Steinberger be9fa124df build: add pkg-config + libasound2-dev to sandbox image 2026-01-04 05:28:08 +00:00
Peter Steinberger ff88f3c075 style: fix lint ordering 2026-01-04 06:27:54 +01:00
Peter Steinberger 1315fc4caf docs: split elevated directives 2026-01-04 05:21:12 +00:00
Peter Steinberger a03895dfa9 fix: default elevated mode to on 2026-01-04 05:19:28 +00:00
Peter Steinberger 40c3898ca1 docs: update changelog for #166 2026-01-04 06:17:07 +01:00
Peter Steinberger 6ea0eb438c style: fix lint formatting 2026-01-04 06:17:07 +01:00
Peter Steinberger 04cd1bd11a fix(macos): bridge wizard option values 2026-01-04 06:17:07 +01:00
Peter Steinberger fe0b3500cc feat: add elevated bash mode 2026-01-04 05:15:59 +00:00
Tu Nombre RealandClaude Opus 4.5 b978cc4e91 feat(macos): add Swift 6 strict concurrency compatibility
Prepares the macOS app for Swift 6 strict concurrency mode by:

1. Adding Sendable conformance to WizardNextResult, WizardStartResult,
   and WizardStatusResult in GatewayModels.swift

2. Adding AnyCodable bridging helpers in OnboardingWizard.swift to
   handle type conflicts between ClawdisProtocol and local module

3. Making CLLocationManagerDelegate methods nonisolated in:
   - MacNodeLocationService.swift
   - PermissionManager.swift (LocationPermissionRequester)

   Using Task { @MainActor in } pattern to safely access MainActor
   state from nonisolated protocol requirements.

These changes are forward-compatible and don't affect behavior on
current Swift versions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 06:09:52 +01:00
Peter Steinberger 72a9e58777 refactor(auto-reply): split reply flow 2026-01-04 05:47:37 +01:00
Peter Steinberger fd91da2b7f fix: log dynamic config reloads 2026-01-04 04:24:50 +00:00
Peter Steinberger 5673f4299a build: add sandbox common image builder 2026-01-04 04:17:13 +00:00
Peter Steinberger 770daadaf7 chore: bump Peekaboo submodule 2026-01-04 05:15:57 +01:00
Peter Steinberger 13c2f22240 refactor: split agent tools 2026-01-04 05:07:44 +01:00
Peter Steinberger f2ce455c8c fix: set writable home for sandbox browser 2026-01-04 03:49:39 +00:00
Peter Steinberger 640ec465d7 chore: bump Peekaboo submodule 2026-01-04 04:46:07 +01:00
Peter Steinberger 70f79bd926 fix: stabilize sandbox browser startup 2026-01-04 03:45:14 +00:00
Peter Steinberger 7d95f43a75 style: fix lint 2026-01-04 03:37:08 +00:00
Peter Steinberger c2f3b653c2 docs: thank scald for Notion skill 2026-01-04 04:36:28 +01:00
Peter SteinbergerandJames Groat 12ba32c724 feat(browser): add remote-capable profiles
Co-authored-by: James Groat <james@groat.com>
2026-01-04 03:33:07 +00:00
Peter Steinberger 0e75aa2716 test: add sessions_send loopback test 2026-01-04 04:30:57 +01:00
Steve Caldwell 44990d837f feat: add Notion API skill
Create and manage Notion pages, databases, and blocks via API.
2026-01-04 04:29:44 +01:00
Shadow 3a28e3562c Discord: tools for uploading emojis and stickers! 2026-01-03 21:20:01 -06:00
Peter Steinberger 24aa3e3311 test: stabilize gateway tests 2026-01-04 04:16:38 +01:00
Peter Steinberger 3c4c2aa98c refactor: split gateway server methods 2026-01-04 04:05:18 +01:00
Peter Steinberger 3ebee63cb3 feat: add clawdhub skill 2026-01-04 04:05:10 +01:00
Peter Steinberger 6d6038b855 docs: tighten wacli skill guidance 2026-01-04 03:45:49 +01:00
Peter Steinberger 55876f7be0 test(agents): cover ping-pong announce flow 2026-01-04 03:41:58 +01:00
Peter Steinberger cd3c42d0c0 feat(sessions): add agent-to-agent ping-pong 2026-01-04 03:37:44 +01:00
148 changed files with 16392 additions and 11027 deletions

No files matched your search

+7 -2
View File
@@ -9,24 +9,28 @@
- Sessions: primary session key is fixed to `main` (or `global` for global scope); `session.mainKey` is ignored.
### Features
- Highlight: agent-to-agent ping-pong (reply-back loop) with `REPLY_SKIP` plus target announce step with `ANNOUNCE_SKIP` (max turns configurable, 0–5).
- Gateway: support `gateway.port` + `CLAWDIS_GATEWAY_PORT` across CLI, TUI, and macOS app.
- Gateway: add config hot reload with hybrid restart strategy (`gateway.reload`) and per-section reload handling.
- UI: centralize tool display metadata and show action/detail summaries across Web Chat, SwiftUI, Android, and the TUI.
- Control UI: support configurable base paths (`gateway.controlUi.basePath`, default unchanged) for hosting under URL prefixes.
- Onboarding: shared wizard engine powering CLI + macOS via gateway wizard RPC.
- Config: expose schema + UI hints for generic config forms (Web UI + future clients).
- Browser: add multi-profile browser control with per-profile remote CDP URLs — thanks @jamesgroat.
- Skills: add blogwatcher skill for RSS/Atom monitoring — thanks @Hyaxia.
- Skills: add Notion API skill — thanks @scald.
- Discord: emit system events for reaction add/remove with per-guild reaction notifications (off|own|all|allowlist) (#140) — thanks @thewilloftheshadow.
- Agent: add optional per-session Docker sandbox for tool execution (`agent.sandbox`) with allow/deny policy and auto-pruning.
- Agent: add sandboxed Chromium browser (CDP + optional noVNC observer) for sandboxed sessions.
- Nodes: add `location.get` with Always/Precise settings on macOS/iOS/Android plus CLI/tool support.
- Sessions: add agent‑to‑agent post step with `ANNOUNCE_SKIP` to suppress channel announcements.
- Android nodes: add `sms.send` with permission-gated capability refresh (#172) — thanks @vsabavat.
### Fixes
- macOS: improve Swift 6 strict concurrency compatibility (#166) — thanks @Nachx639.
- CI: fix lint ordering after merge cleanup (#156) — thanks @steipete.
- CI: consolidate checks to avoid redundant installs (#144) — thanks @thewilloftheshadow.
- WhatsApp: support `gifPlayback` for MP4 GIF sends via CLI/gateway.
- Gateway: log config hot reloads for dynamic-read changes without restarts.
- Sessions: prevent `sessions_send` timeouts by running nested agent turns on a separate lane.
- Sessions: use per-send run IDs for gateway agent calls to avoid wait collisions.
- Auto-reply: drop final payloads when block streaming to avoid duplicate Discord sends.
@@ -73,7 +77,8 @@
- Queue: clarify steer-backlog behavior with inline commands and update examples for streaming surfaces.
- Sandbox: document per-session agent sandbox setup, browser image, and Docker build.
- macOS: clarify menu bar uses sessionKey from agent events.
- Sessions: document agent-to-agent post step and `ANNOUNCE_SKIP`.
- Sessions: document agent-to-agent reply loop (`REPLY_SKIP`) and announce step (`ANNOUNCE_SKIP`).
- Skills: clarify wacli third-party messaging scope and JID format examples.
## 2.0.0-beta5 — 2026-01-03
@@ -14,9 +14,13 @@
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.SEND_SMS" />
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<uses-feature
android:name="android.hardware.telephony"
android:required="false" />
<application
android:name=".NodeApp"
@@ -39,6 +39,7 @@ class MainActivity : ComponentActivity() {
screenCaptureRequester = ScreenCaptureRequester(this)
viewModel.camera.attachLifecycleOwner(this)
viewModel.camera.attachPermissionRequester(permissionRequester)
viewModel.sms.attachPermissionRequester(permissionRequester)
viewModel.screenRecorder.attachScreenCaptureRequester(screenCaptureRequester)
viewModel.screenRecorder.attachPermissionRequester(permissionRequester)
@@ -7,6 +7,7 @@ import com.clawdis.android.chat.OutgoingAttachment
import com.clawdis.android.node.CameraCaptureManager
import com.clawdis.android.node.CanvasController
import com.clawdis.android.node.ScreenRecordManager
import com.clawdis.android.node.SmsManager
import kotlinx.coroutines.flow.StateFlow
class MainViewModel(app: Application) : AndroidViewModel(app) {
@@ -15,6 +16,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val canvas: CanvasController = runtime.canvas
val camera: CameraCaptureManager = runtime.camera
val screenRecorder: ScreenRecordManager = runtime.screenRecorder
val sms: SmsManager = runtime.sms
val bridges: StateFlow<List<BridgeEndpoint>> = runtime.bridges
val discoveryStatusText: StateFlow<String> = runtime.discoveryStatusText
@@ -116,6 +118,10 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
runtime.setTalkEnabled(enabled)
}
fun refreshBridgeHello() {
runtime.refreshBridgeHello()
}
fun connect(endpoint: BridgeEndpoint) {
runtime.connect(endpoint)
}
@@ -21,13 +21,15 @@ import com.clawdis.android.node.LocationCaptureManager
import com.clawdis.android.BuildConfig
import com.clawdis.android.node.CanvasController
import com.clawdis.android.node.ScreenRecordManager
import com.clawdis.android.node.SmsManager
import com.clawdis.android.protocol.ClawdisCapability
import com.clawdis.android.protocol.ClawdisCameraCommand
import com.clawdis.android.protocol.ClawdisCanvasA2UIAction
import com.clawdis.android.protocol.ClawdisCanvasA2UICommand
import com.clawdis.android.protocol.ClawdisCanvasCommand
import com.clawdis.android.protocol.ClawdisLocationCommand
import com.clawdis.android.protocol.ClawdisScreenCommand
import com.clawdis.android.protocol.ClawdisLocationCommand
import com.clawdis.android.protocol.ClawdisSmsCommand
import com.clawdis.android.voice.TalkModeManager
import com.clawdis.android.voice.VoiceWakeManager
import kotlinx.coroutines.CoroutineScope
@@ -61,6 +63,7 @@ class NodeRuntime(context: Context) {
val camera = CameraCaptureManager(appContext)
val location = LocationCaptureManager(appContext)
val screenRecorder = ScreenRecordManager(appContext)
val sms = SmsManager(appContext)
private val json = Json { ignoreUnknownKeys = true }
private val externalAudioCaptureActive = MutableStateFlow(false)
@@ -364,69 +367,112 @@ class NodeRuntime(context: Context) {
prefs.setTalkEnabled(value)
}
private fun buildInvokeCommands(): List<String> =
buildList {
add(ClawdisCanvasCommand.Present.rawValue)
add(ClawdisCanvasCommand.Hide.rawValue)
add(ClawdisCanvasCommand.Navigate.rawValue)
add(ClawdisCanvasCommand.Eval.rawValue)
add(ClawdisCanvasCommand.Snapshot.rawValue)
add(ClawdisCanvasA2UICommand.Push.rawValue)
add(ClawdisCanvasA2UICommand.PushJSONL.rawValue)
add(ClawdisCanvasA2UICommand.Reset.rawValue)
add(ClawdisScreenCommand.Record.rawValue)
if (cameraEnabled.value) {
add(ClawdisCameraCommand.Snap.rawValue)
add(ClawdisCameraCommand.Clip.rawValue)
}
if (locationMode.value != LocationMode.Off) {
add(ClawdisLocationCommand.Get.rawValue)
}
if (sms.canSendSms()) {
add(ClawdisSmsCommand.Send.rawValue)
}
}
private fun buildCapabilities(): List<String> =
buildList {
add(ClawdisCapability.Canvas.rawValue)
add(ClawdisCapability.Screen.rawValue)
if (cameraEnabled.value) add(ClawdisCapability.Camera.rawValue)
if (sms.canSendSms()) add(ClawdisCapability.Sms.rawValue)
if (voiceWakeMode.value != VoiceWakeMode.Off && hasRecordAudioPermission()) {
add(ClawdisCapability.VoiceWake.rawValue)
}
if (locationMode.value != LocationMode.Off) {
add(ClawdisCapability.Location.rawValue)
}
}
private fun buildPairingHello(token: String?): BridgePairingClient.Hello {
val modelIdentifier = listOfNotNull(Build.MANUFACTURER, Build.MODEL)
.joinToString(" ")
.trim()
.ifEmpty { null }
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
val advertisedVersion =
if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
"$versionName-dev"
} else {
versionName
}
return BridgePairingClient.Hello(
nodeId = instanceId.value,
displayName = displayName.value,
token = token,
platform = "Android",
version = advertisedVersion,
deviceFamily = "Android",
modelIdentifier = modelIdentifier,
caps = buildCapabilities(),
commands = buildInvokeCommands(),
)
}
private fun buildSessionHello(token: String?): BridgeSession.Hello {
val modelIdentifier = listOfNotNull(Build.MANUFACTURER, Build.MODEL)
.joinToString(" ")
.trim()
.ifEmpty { null }
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
val advertisedVersion =
if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
"$versionName-dev"
} else {
versionName
}
return BridgeSession.Hello(
nodeId = instanceId.value,
displayName = displayName.value,
token = token,
platform = "Android",
version = advertisedVersion,
deviceFamily = "Android",
modelIdentifier = modelIdentifier,
caps = buildCapabilities(),
commands = buildInvokeCommands(),
)
}
fun refreshBridgeHello() {
scope.launch {
if (!_isConnected.value) return@launch
val token = prefs.loadBridgeToken()
if (token.isNullOrBlank()) return@launch
session.updateHello(buildSessionHello(token))
}
}
fun connect(endpoint: BridgeEndpoint) {
scope.launch {
_statusText.value = "Connecting…"
val storedToken = prefs.loadBridgeToken()
val modelIdentifier = listOfNotNull(Build.MANUFACTURER, Build.MODEL)
.joinToString(" ")
.trim()
.ifEmpty { null }
val invokeCommands =
buildList {
add(ClawdisCanvasCommand.Present.rawValue)
add(ClawdisCanvasCommand.Hide.rawValue)
add(ClawdisCanvasCommand.Navigate.rawValue)
add(ClawdisCanvasCommand.Eval.rawValue)
add(ClawdisCanvasCommand.Snapshot.rawValue)
add(ClawdisCanvasA2UICommand.Push.rawValue)
add(ClawdisCanvasA2UICommand.PushJSONL.rawValue)
add(ClawdisCanvasA2UICommand.Reset.rawValue)
add(ClawdisScreenCommand.Record.rawValue)
if (cameraEnabled.value) {
add(ClawdisCameraCommand.Snap.rawValue)
add(ClawdisCameraCommand.Clip.rawValue)
}
if (locationMode.value != LocationMode.Off) {
add(ClawdisLocationCommand.Get.rawValue)
}
}
val resolved =
if (storedToken.isNullOrBlank()) {
_statusText.value = "Pairing…"
val caps = buildList {
add(ClawdisCapability.Canvas.rawValue)
add(ClawdisCapability.Screen.rawValue)
if (cameraEnabled.value) add(ClawdisCapability.Camera.rawValue)
if (voiceWakeMode.value != VoiceWakeMode.Off && hasRecordAudioPermission()) {
add(ClawdisCapability.VoiceWake.rawValue)
}
if (locationMode.value != LocationMode.Off) {
add(ClawdisCapability.Location.rawValue)
}
}
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
val advertisedVersion =
if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
"$versionName-dev"
} else {
versionName
}
BridgePairingClient().pairAndHello(
endpoint = endpoint,
hello =
BridgePairingClient.Hello(
nodeId = instanceId.value,
displayName = displayName.value,
token = null,
platform = "Android",
version = advertisedVersion,
deviceFamily = "Android",
modelIdentifier = modelIdentifier,
caps = caps,
commands = invokeCommands,
),
hello = buildPairingHello(token = null),
)
} else {
BridgePairingClient.PairResult(ok = true, token = storedToken.trim())
@@ -440,38 +486,9 @@ class NodeRuntime(context: Context) {
val authToken = requireNotNull(resolved.token).trim()
prefs.saveBridgeToken(authToken)
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
val advertisedVersion =
if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
"$versionName-dev"
} else {
versionName
}
session.connect(
endpoint = endpoint,
hello =
BridgeSession.Hello(
nodeId = instanceId.value,
displayName = displayName.value,
token = authToken,
platform = "Android",
version = advertisedVersion,
deviceFamily = "Android",
modelIdentifier = modelIdentifier,
caps =
buildList {
add(ClawdisCapability.Canvas.rawValue)
add(ClawdisCapability.Screen.rawValue)
if (cameraEnabled.value) add(ClawdisCapability.Camera.rawValue)
if (voiceWakeMode.value != VoiceWakeMode.Off && hasRecordAudioPermission()) {
add(ClawdisCapability.VoiceWake.rawValue)
}
if (locationMode.value != LocationMode.Off) {
add(ClawdisCapability.Location.rawValue)
}
},
commands = invokeCommands,
),
hello = buildSessionHello(token = authToken),
)
}
}
@@ -911,6 +928,17 @@ class NodeRuntime(context: Context) {
_screenRecordActive.value = false
}
}
ClawdisSmsCommand.Send.rawValue -> {
val res = sms.send(paramsJson)
if (res.ok) {
BridgeSession.InvokeResult.ok(res.payloadJson)
} else {
val error = res.error ?: "SMS_SEND_FAILED"
val idx = error.indexOf(':')
val code = if (idx > 0) error.substring(0, idx).trim() else "SMS_SEND_FAILED"
BridgeSession.InvokeResult.error(code = code, message = error)
}
}
else ->
BridgeSession.InvokeResult.error(
code = "INVALID_REQUEST",
@@ -115,7 +115,7 @@ class PermissionRequester(private val activity: ComponentActivity) {
private fun buildRationaleMessage(permissions: List<String>): String {
val labels = permissions.map { permissionLabel(it) }
return "Clawdis needs ${labels.joinToString(", ")} to capture camera media."
return "Clawdis needs ${labels.joinToString(", ")} permissions to continue."
}
private fun buildSettingsMessage(permissions: List<String>): String {
@@ -127,6 +127,7 @@ class PermissionRequester(private val activity: ComponentActivity) {
when (permission) {
Manifest.permission.CAMERA -> "Camera"
Manifest.permission.RECORD_AUDIO -> "Microphone"
Manifest.permission.SEND_SMS -> "SMS"
else -> permission
}
}
@@ -75,6 +75,13 @@ class BridgeSession(
}
}
suspend fun updateHello(hello: Hello) {
val target = desired ?: return
desired = target.first to hello
val conn = currentConnection ?: return
conn.sendJson(buildHelloJson(hello))
}
fun disconnect() {
desired = null
// Unblock connectOnce() read loop. Coroutine cancellation alone won't interrupt BufferedReader.readLine().
@@ -196,20 +203,7 @@ class BridgeSession(
currentConnection = conn
try {
conn.sendJson(
buildJsonObject {
put("type", JsonPrimitive("hello"))
put("nodeId", JsonPrimitive(hello.nodeId))
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
hello.token?.let { put("token", JsonPrimitive(it)) }
hello.platform?.let { put("platform", JsonPrimitive(it)) }
hello.version?.let { put("version", JsonPrimitive(it)) }
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
},
)
conn.sendJson(buildHelloJson(hello))
val firstLine = reader.readLine() ?: throw IllegalStateException("bridge closed connection")
val first = json.parseToJsonElement(firstLine).asObjectOrNull()
@@ -307,6 +301,20 @@ class BridgeSession(
}
}
private fun buildHelloJson(hello: Hello): JsonObject =
buildJsonObject {
put("type", JsonPrimitive("hello"))
put("nodeId", JsonPrimitive(hello.nodeId))
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
hello.token?.let { put("token", JsonPrimitive(it)) }
hello.platform?.let { put("platform", JsonPrimitive(it)) }
hello.version?.let { put("version", JsonPrimitive(it)) }
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
}
private fun normalizeCanvasHostUrl(raw: String?, endpoint: BridgeEndpoint): String? {
val trimmed = raw?.trim().orEmpty()
val parsed = trimmed.takeIf { it.isNotBlank() }?.let { runCatching { URI(it) }.getOrNull() }
@@ -0,0 +1,230 @@
package com.clawdis.android.node
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.telephony.SmsManager as AndroidSmsManager
import androidx.core.content.ContextCompat
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.encodeToString
import com.clawdis.android.PermissionRequester
/**
* Sends SMS messages via the Android SMS API.
* Requires SEND_SMS permission to be granted.
*/
class SmsManager(private val context: Context) {
private val json = JsonConfig
@Volatile private var permissionRequester: PermissionRequester? = null
data class SendResult(
val ok: Boolean,
val to: String,
val message: String?,
val error: String? = null,
val payloadJson: String,
)
internal data class ParsedParams(
val to: String,
val message: String,
)
internal sealed class ParseResult {
data class Ok(val params: ParsedParams) : ParseResult()
data class Error(
val error: String,
val to: String = "",
val message: String? = null,
) : ParseResult()
}
internal data class SendPlan(
val parts: List<String>,
val useMultipart: Boolean,
)
companion object {
internal val JsonConfig = Json { ignoreUnknownKeys = true }
internal fun parseParams(paramsJson: String?, json: Json = JsonConfig): ParseResult {
val params = paramsJson?.trim().orEmpty()
if (params.isEmpty()) {
return ParseResult.Error(error = "INVALID_REQUEST: paramsJSON required")
}
val obj = try {
json.parseToJsonElement(params).jsonObject
} catch (_: Throwable) {
null
}
if (obj == null) {
return ParseResult.Error(error = "INVALID_REQUEST: expected JSON object")
}
val to = (obj["to"] as? JsonPrimitive)?.content?.trim().orEmpty()
val message = (obj["message"] as? JsonPrimitive)?.content.orEmpty()
if (to.isEmpty()) {
return ParseResult.Error(
error = "INVALID_REQUEST: 'to' phone number required",
message = message,
)
}
if (message.isEmpty()) {
return ParseResult.Error(
error = "INVALID_REQUEST: 'message' text required",
to = to,
)
}
return ParseResult.Ok(ParsedParams(to = to, message = message))
}
internal fun buildSendPlan(
message: String,
divider: (String) -> List<String>,
): SendPlan {
val parts = divider(message).ifEmpty { listOf(message) }
return SendPlan(parts = parts, useMultipart = parts.size > 1)
}
internal fun buildPayloadJson(
json: Json = JsonConfig,
ok: Boolean,
to: String,
error: String?,
): String {
val payload =
mutableMapOf<String, JsonElement>(
"ok" to JsonPrimitive(ok),
"to" to JsonPrimitive(to),
)
if (!ok) {
payload["error"] = JsonPrimitive(error ?: "SMS_SEND_FAILED")
}
return json.encodeToString(JsonObject.serializer(), JsonObject(payload))
}
}
fun hasSmsPermission(): Boolean {
return ContextCompat.checkSelfPermission(
context,
Manifest.permission.SEND_SMS
) == PackageManager.PERMISSION_GRANTED
}
fun canSendSms(): Boolean {
return hasSmsPermission() && hasTelephonyFeature()
}
fun hasTelephonyFeature(): Boolean {
return context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
}
fun attachPermissionRequester(requester: PermissionRequester) {
permissionRequester = requester
}
/**
* Send an SMS message.
*
* @param paramsJson JSON with "to" (phone number) and "message" (text) fields
* @return SendResult indicating success or failure
*/
suspend fun send(paramsJson: String?): SendResult {
if (!hasTelephonyFeature()) {
return errorResult(
error = "SMS_UNAVAILABLE: telephony not available",
)
}
if (!ensureSmsPermission()) {
return errorResult(
error = "SMS_PERMISSION_REQUIRED: grant SMS permission",
)
}
val parseResult = parseParams(paramsJson, json)
if (parseResult is ParseResult.Error) {
return errorResult(
error = parseResult.error,
to = parseResult.to,
message = parseResult.message,
)
}
val params = (parseResult as ParseResult.Ok).params
return try {
val smsManager = context.getSystemService(AndroidSmsManager::class.java)
?: throw IllegalStateException("SMS_UNAVAILABLE: SmsManager not available")
val plan = buildSendPlan(params.message) { smsManager.divideMessage(it) }
if (plan.useMultipart) {
smsManager.sendMultipartTextMessage(
params.to, // destination
null, // service center (null = default)
ArrayList(plan.parts), // message parts
null, // sent intents
null, // delivery intents
)
} else {
smsManager.sendTextMessage(
params.to, // destination
null, // service center (null = default)
params.message,// message
null, // sent intent
null, // delivery intent
)
}
okResult(to = params.to, message = params.message)
} catch (e: SecurityException) {
errorResult(
error = "SMS_PERMISSION_REQUIRED: ${e.message}",
to = params.to,
message = params.message,
)
} catch (e: Throwable) {
errorResult(
error = "SMS_SEND_FAILED: ${e.message ?: "unknown error"}",
to = params.to,
message = params.message,
)
}
}
private suspend fun ensureSmsPermission(): Boolean {
if (hasSmsPermission()) return true
val requester = permissionRequester ?: return false
val results = requester.requestIfMissing(listOf(Manifest.permission.SEND_SMS))
return results[Manifest.permission.SEND_SMS] == true
}
private fun okResult(to: String, message: String): SendResult {
return SendResult(
ok = true,
to = to,
message = message,
error = null,
payloadJson = buildPayloadJson(json = json, ok = true, to = to, error = null),
)
}
private fun errorResult(error: String, to: String = "", message: String? = null): SendResult {
return SendResult(
ok = false,
to = to,
message = message,
error = error,
payloadJson = buildPayloadJson(json = json, ok = false, to = to, error = error),
)
}
}
@@ -4,6 +4,7 @@ enum class ClawdisCapability(val rawValue: String) {
Canvas("canvas"),
Camera("camera"),
Screen("screen"),
Sms("sms"),
VoiceWake("voiceWake"),
Location("location"),
}
@@ -51,6 +52,15 @@ enum class ClawdisScreenCommand(val rawValue: String) {
}
}
enum class ClawdisSmsCommand(val rawValue: String) {
Send("sms.send"),
;
companion object {
const val NamespacePrefix: String = "sms."
}
}
enum class ClawdisLocationCommand(val rawValue: String) {
Get("location.get"),
;
@@ -149,6 +149,23 @@ fun SettingsSheet(viewModel: MainViewModel) {
// Status text is handled by NodeRuntime.
}
val smsPermissionAvailable =
remember {
context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
}
var smsPermissionGranted by
remember {
mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) ==
PackageManager.PERMISSION_GRANTED,
)
}
val smsPermissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
smsPermissionGranted = granted
viewModel.refreshBridgeHello()
}
fun setCameraEnabledChecked(checked: Boolean) {
if (!checked) {
viewModel.setCameraEnabled(false)
@@ -233,7 +250,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
// Order parity: Node → Bridge → Voice → Camera → Location → Screen.
// Order parity: Node → Bridge → Voice → Camera → Messaging → Location → Screen.
item { Text("Node", style = MaterialTheme.typography.titleSmall) }
item {
OutlinedTextField(
@@ -507,6 +524,46 @@ fun SettingsSheet(viewModel: MainViewModel) {
item { HorizontalDivider() }
// Messaging
item { Text("Messaging", style = MaterialTheme.typography.titleSmall) }
item {
val buttonLabel =
when {
!smsPermissionAvailable -> "Unavailable"
smsPermissionGranted -> "Manage"
else -> "Grant"
}
ListItem(
headlineContent = { Text("SMS Permission") },
supportingContent = {
Text(
if (smsPermissionAvailable) {
"Allow the bridge to send SMS from this device."
} else {
"SMS requires a device with telephony hardware."
},
)
},
trailingContent = {
Button(
onClick = {
if (!smsPermissionAvailable) return@Button
if (smsPermissionGranted) {
openAppSettings(context)
} else {
smsPermissionLauncher.launch(Manifest.permission.SEND_SMS)
}
},
enabled = smsPermissionAvailable,
) {
Text(buttonLabel)
}
},
)
}
item { HorizontalDivider() }
// Location
item { Text("Location", style = MaterialTheme.typography.titleSmall) }
item {
@@ -0,0 +1,91 @@
package com.clawdis.android.node
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class SmsManagerTest {
private val json = SmsManager.JsonConfig
@Test
fun parseParamsRejectsEmptyPayload() {
val result = SmsManager.parseParams("", json)
assertTrue(result is SmsManager.ParseResult.Error)
val error = result as SmsManager.ParseResult.Error
assertEquals("INVALID_REQUEST: paramsJSON required", error.error)
}
@Test
fun parseParamsRejectsInvalidJson() {
val result = SmsManager.parseParams("not-json", json)
assertTrue(result is SmsManager.ParseResult.Error)
val error = result as SmsManager.ParseResult.Error
assertEquals("INVALID_REQUEST: expected JSON object", error.error)
}
@Test
fun parseParamsRejectsNonObjectJson() {
val result = SmsManager.parseParams("[]", json)
assertTrue(result is SmsManager.ParseResult.Error)
val error = result as SmsManager.ParseResult.Error
assertEquals("INVALID_REQUEST: expected JSON object", error.error)
}
@Test
fun parseParamsRejectsMissingTo() {
val result = SmsManager.parseParams("{\"message\":\"Hi\"}", json)
assertTrue(result is SmsManager.ParseResult.Error)
val error = result as SmsManager.ParseResult.Error
assertEquals("INVALID_REQUEST: 'to' phone number required", error.error)
assertEquals("Hi", error.message)
}
@Test
fun parseParamsRejectsMissingMessage() {
val result = SmsManager.parseParams("{\"to\":\"+1234\"}", json)
assertTrue(result is SmsManager.ParseResult.Error)
val error = result as SmsManager.ParseResult.Error
assertEquals("INVALID_REQUEST: 'message' text required", error.error)
assertEquals("+1234", error.to)
}
@Test
fun parseParamsTrimsToField() {
val result = SmsManager.parseParams("{\"to\":\" +1555 \",\"message\":\"Hello\"}", json)
assertTrue(result is SmsManager.ParseResult.Ok)
val ok = result as SmsManager.ParseResult.Ok
assertEquals("+1555", ok.params.to)
assertEquals("Hello", ok.params.message)
}
@Test
fun buildPayloadJsonEscapesFields() {
val payload = SmsManager.buildPayloadJson(
json = json,
ok = false,
to = "+1\"23",
error = "SMS_SEND_FAILED: \"nope\"",
)
val parsed = json.parseToJsonElement(payload).jsonObject
assertEquals("false", parsed["ok"]?.jsonPrimitive?.content)
assertEquals("+1\"23", parsed["to"]?.jsonPrimitive?.content)
assertEquals("SMS_SEND_FAILED: \"nope\"", parsed["error"]?.jsonPrimitive?.content)
}
@Test
fun buildSendPlanUsesMultipartWhenMultipleParts() {
val plan = SmsManager.buildSendPlan("hello") { listOf("a", "b") }
assertTrue(plan.useMultipart)
assertEquals(listOf("a", "b"), plan.parts)
}
@Test
fun buildSendPlanFallsBackToSinglePartWhenDividerEmpty() {
val plan = SmsManager.buildSendPlan("hello") { emptyList() }
assertFalse(plan.useMultipart)
assertEquals(listOf("hello"), plan.parts)
}
}
@@ -646,6 +646,7 @@ public struct SessionsPatchParams: Codable {
public let key: String
public let thinkinglevel: AnyCodable?
public let verboselevel: AnyCodable?
public let elevatedlevel: AnyCodable?
public let model: AnyCodable?
public let sendpolicy: AnyCodable?
public let groupactivation: AnyCodable?
@@ -654,6 +655,7 @@ public struct SessionsPatchParams: Codable {
key: String,
thinkinglevel: AnyCodable?,
verboselevel: AnyCodable?,
elevatedlevel: AnyCodable?,
model: AnyCodable?,
sendpolicy: AnyCodable?,
groupactivation: AnyCodable?
@@ -661,6 +663,7 @@ public struct SessionsPatchParams: Codable {
self.key = key
self.thinkinglevel = thinkinglevel
self.verboselevel = verboselevel
self.elevatedlevel = elevatedlevel
self.model = model
self.sendpolicy = sendpolicy
self.groupactivation = groupactivation
@@ -669,6 +672,7 @@ public struct SessionsPatchParams: Codable {
case key
case thinkinglevel = "thinkingLevel"
case verboselevel = "verboseLevel"
case elevatedlevel = "elevatedLevel"
case model
case sendpolicy = "sendPolicy"
case groupactivation = "groupActivation"
@@ -871,7 +875,7 @@ public struct WizardStep: Codable {
}
}
public struct WizardNextResult: Codable, Sendable {
public struct WizardNextResult: Codable {
public let done: Bool
public let step: [String: AnyCodable]?
public let status: AnyCodable?
@@ -896,7 +900,7 @@ public struct WizardNextResult: Codable, Sendable {
}
}
public struct WizardStartResult: Codable, Sendable {
public struct WizardStartResult: Codable {
public let sessionid: String
public let done: Bool
public let step: [String: AnyCodable]?
@@ -925,7 +929,7 @@ public struct WizardStartResult: Codable, Sendable {
}
}
public struct WizardStatusResult: Codable, Sendable {
public struct WizardStatusResult: Codable {
public let status: AnyCodable
public let error: String?
+1
View File
@@ -16,6 +16,7 @@ Key parameters:
- `yieldMs` (default 10000): auto‑background after this delay
- `background` (bool): background immediately
- `timeout` (seconds, default 1800): kill the process after this timeout
- `elevated` (bool): run on host if elevated mode is enabled/allowed
- Need a real TTY? Use the tmux skill.
- `workdir`, `env`
+1
View File
@@ -15,6 +15,7 @@ Run shell commands in the workspace. Supports foreground + background execution
- `yieldMs` (default 10000): auto-background after delay
- `background` (bool): background immediately
- `timeout` (seconds, default 1800): kill on expiry
- `elevated` (bool): run on host if elevated mode is enabled/allowed
- Need a real TTY? Use the tmux skill.
## Examples
+60
View File
@@ -153,6 +153,59 @@ Hooks (arming):
Clawdis should treat "open/closed" as a health check (fast path), not by scanning
global Chrome processes (avoid false positives).
## Multi-profile support
Clawdis supports multiple named browser profiles, each with:
- Dedicated CDP port (auto-allocated from 18800-18899) **or** a per-profile CDP URL
- Persistent user data directory (`~/.clawdis/browser/<name>/user-data/`)
- Unique color for visual distinction
### Configuration
```json
{
"browser": {
"enabled": true,
"defaultProfile": "clawd",
"profiles": {
"clawd": { "cdpPort": 18800, "color": "#FF4500" },
"work": { "cdpPort": 18801, "color": "#0066CC" },
"remote": { "cdpUrl": "http://10.0.0.42:9222", "color": "#00AA00" }
}
}
}
```
### Profile actions
- `GET /profiles` — list all profiles with status
- `POST /profiles/create` `{ name, color?, cdpUrl? }` — create new profile (auto-allocates port if no `cdpUrl`)
- `DELETE /profiles/:name` — delete profile (stops browser + removes user data for local profiles)
- `POST /reset-profile?profile=<name>` — kill orphan process on profile's port (local profiles only)
### Profile parameter
All existing endpoints accept optional `?profile=<name>` query parameter:
- `GET /?profile=work` — status for work profile
- `POST /start?profile=work` — start work profile browser
- `GET /tabs?profile=work` — list tabs for work profile
- etc.
When `profile` is omitted, uses `browser.defaultProfile` (defaults to "clawd").
### Profile naming rules
- Lowercase alphanumeric characters and hyphens only
- Must start with a letter or number (not a hyphen)
- Maximum 64 characters
- Examples: `clawd`, `work`, `my-project-1`
### Port allocation
Ports are allocated from range 18800-18899 (~100 profiles max). This is far more
than practical use — memory and CPU exhaustion occur well before port exhaustion.
Ports are allocated once at profile creation and persisted permanently.
Remote profiles are attach-only and do **not** use the local port range.
## Interaction with the agent (clawd)
The agent should use browser tools only when:
@@ -168,6 +221,13 @@ The agent should not assume tabs are ephemeral. It should:
## CLI quick reference (one example each)
All commands accept `--profile <name>` to target a specific profile (default: `clawd`).
Profile management:
- `clawdis browser profiles`
- `clawdis browser create-profile --name work`
- `clawdis browser create-profile --name remote --cdp-url http://10.0.0.42:9222`
- `clawdis browser delete-profile --name work`
Basics:
- `clawdis browser status`
- `clawdis browser start`
+44 -2
View File
@@ -388,6 +388,7 @@ Controls the embedded agent runtime (model/thinking/verbose/timeouts).
},
thinkingDefault: "low",
verboseDefault: "off",
elevatedDefault: "on",
timeoutSeconds: 600,
mediaMaxMb: 5,
heartbeat: {
@@ -439,6 +440,31 @@ Z.AI models are available as `zai/<model>` (e.g. `zai/glm-4.7`) and require
- `timeoutSec`: auto-kill after this runtime (seconds, default 1800)
- `cleanupMs`: how long to keep finished sessions in memory (ms, default 1800000)
`agent.elevated` controls elevated (host) bash access:
- `enabled`: allow elevated mode (default true)
- `allowFrom`: per-surface allowlists (empty = disabled)
- `whatsapp`: E.164 numbers
- `telegram`: chat ids or usernames
- `discord`: user ids or usernames (falls back to `discord.dm.allowFrom` if omitted)
- `signal`: E.164 numbers
- `imessage`: handles/chat ids
- `webchat`: session ids or usernames
Example:
```json5
{
agent: {
elevated: {
enabled: true,
allowFrom: {
whatsapp: ["+15555550123"],
discord: ["steipete", "1234567890123"]
}
}
}
}
```
`agent.maxConcurrent` sets the maximum number of embedded agent runs that can
execute in parallel across sessions. Each session is still serialized (one run
per session key at a time). Default: 1.
@@ -621,6 +647,10 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto
resetTriggers: ["/new", "/reset"],
store: "~/.clawdis/sessions/sessions.json",
// mainKey is ignored; primary key is fixed to "main"
agentToAgent: {
// Max ping-pong reply turns between requester/target (0–5).
maxPingPongTurns: 5
},
sendPolicy: {
rules: [
{ action: "deny", match: { surface: "discord", chatType: "group" } }
@@ -632,6 +662,7 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto
```
Fields:
- `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (0–5, default 5).
- `sendPolicy.default`: `allow` or `deny` fallback when no rule matches.
- `sendPolicy.rules[]`: match by `surface` (provider), `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow.
@@ -687,11 +718,16 @@ Example:
### `browser` (clawd-managed Chrome)
Clawdis can start a **dedicated, isolated** Chrome/Chromium instance for clawd and expose a small loopback control server.
Profiles can point at a **remote** Chrome via `profiles.<name>.cdpUrl`. Remote
profiles are attach-only (start/stop/reset are disabled).
`browser.cdpUrl` remains for legacy single-profile configs and as the base
scheme/host for profiles that only set `cdpPort`.
Defaults:
- enabled: `true`
- control URL: `http://127.0.0.1:18791` (CDP uses `18792`)
- CDP URL: `http://127.0.0.1:18792` (control URL + 1)
- CDP URL: `http://127.0.0.1:18792` (control URL + 1, legacy single-profile)
- profile color: `#FF4500` (lobster-orange)
- Note: the control server is started by the running gateway (Clawdis.app menubar, or `clawdis gateway`).
@@ -700,7 +736,13 @@ Defaults:
browser: {
enabled: true,
controlUrl: "http://127.0.0.1:18791",
// cdpUrl: "http://127.0.0.1:18792", // override for remote CDP
// cdpUrl: "http://127.0.0.1:18792", // legacy single-profile override
defaultProfile: "clawd",
profiles: {
clawd: { cdpPort: 18800, color: "#FF4500" },
work: { cdpPort: 18801, color: "#0066CC" },
remote: { cdpUrl: "http://10.0.0.42:9222", color: "#00AA00" }
},
color: "#FF4500",
// Advanced:
// headless: false,
+31
View File
@@ -0,0 +1,31 @@
---
summary: "Elevated bash mode and /elevated directives"
read_when:
- Adjusting elevated mode defaults, allowlists, or slash command behavior
---
# Elevated Mode (/elevated directives)
## What it does
- Elevated mode allows the bash tool to run with elevated privileges when the feature is available and the sender is approved.
- Directive forms: `/elevated on`, `/elevated off`, `/elev on`, `/elev off`.
- Only `on|off` are accepted; anything else returns a hint and does not change state.
## Resolution order
1. Inline directive on the message (applies only to that message).
2. Session override (set by sending a directive-only message).
3. Global default (`agent.elevatedDefault` in config).
## Setting a session default
- Send a message that is **only** the directive (whitespace allowed), e.g. `/elevated on`.
- Confirmation reply is sent (`Elevated mode enabled.` / `Elevated mode disabled.`).
- If elevated access is disabled or the sender is not on the approved allowlist, the directive replies `elevated is not available right now.` and does not change session state.
## Availability + allowlists
- Feature gate: `agent.elevated.enabled` (default can be off via config even if the code supports it).
- Sender allowlist: `agent.elevated.allowFrom` with per-surface allowlists (e.g. `discord`, `whatsapp`).
- Both must pass; otherwise elevated is treated as unavailable.
- Discord fallback: if `agent.elevated.allowFrom.discord` is omitted, the `discord.dm.allowFrom` list is used as a fallback. Set `agent.elevated.allowFrom.discord` (even `[]`) to override.
## Logging + status
- Elevated bash calls are logged at info level.
- Session status includes elevated mode (e.g. `elevated=on`).
+1
View File
@@ -494,6 +494,7 @@ Quick reference (send these in chat):
| `/new` or `/reset` | Reset the session |
| `/think <level>` | Set thinking level (off\|minimal\|low\|medium\|high) |
| `/verbose on\|off` | Toggle verbose mode |
| `/elevated on\|off` | Toggle elevated bash mode (approved senders only) |
| `/activation mention\|always` | Group activation (owner-only) |
| `/model <name>` | Switch AI model (see below) |
| `/queue instant\|batch\|serial` | Message queuing mode |
+14
View File
@@ -110,6 +110,20 @@ Notes:
- “Always” requires system permission; background fetch is best-effort.
- The response includes lat/lon, accuracy (meters), and timestamp.
## SMS (Android nodes)
Android nodes can expose `sms.send` when the user grants **SMS** permission and the device supports telephony.
Low-level invoke:
```bash
clawdis nodes invoke --node <idOrNameOrIp> --command sms.send --params '{"to":"+15555550123","message":"Hello from Clawdis"}'
```
Notes:
- The permission prompt must be accepted on the Android device before the capability is advertised.
- Wi-Fi-only devices without telephony will not advertise `sms.send`.
## System commands (mac node)
The macOS node exposes `system.run` and `system.notify`.
+7 -4
View File
@@ -76,11 +76,14 @@ Behavior:
- If the run fails: `{ runId, status: "error", error }`.
- Waits via gateway `agent.wait` (server-side) so reconnects don't drop the wait.
- Agent-to-agent message context is injected for the primary run.
- After the primary run completes, Clawdis starts an **agent-to-agent post step**:
- The agent can reply with the announcement to post to the target session.
- To stay silent, reply exactly `ANNOUNCE_SKIP`.
- After the primary run completes, Clawdis runs a **reply-back loop**:
- Round 2+ alternates between requester and target agents.
- Reply exactly `REPLY_SKIP` to stop the ping‑pong.
- Max turns is `session.agentToAgent.maxPingPongTurns` (0–5, default 5).
- Once the loop ends, Clawdis runs the **agent‑to‑agent announce step** (target agent only):
- Reply exactly `ANNOUNCE_SKIP` to stay silent.
- Any other reply is sent to the target channel.
- The post step includes the original request and round‑1 reply in context.
- Announce step includes the original request + round‑1 reply + latest ping‑pong reply.
## Provider Field
- For groups, `provider` is the `surface` recorded on the session entry.
+5 -2
View File
@@ -1,7 +1,7 @@
---
summary: "Directive syntax for /think levels and how they affect model reasoning"
summary: "Directive syntax for /think + /verbose and how they affect model reasoning"
read_when:
- Adjusting thinking level parsing or defaults
- Adjusting thinking or verbose directive parsing or defaults
---
# Thinking Levels (/think directives)
@@ -34,6 +34,9 @@ read_when:
- Inline directive affects only that message; session/global defaults apply otherwise.
- When verbose is on, agents that emit structured tool results (Pi, other JSON agents) send each tool result back as its own metadata-only message, prefixed with `<emoji> <tool-name>: <arg>` when available (path/command); the tool output itself is not forwarded. These tool summaries are sent as soon as each tool finishes (separate bubbles), not as streaming deltas. If you toggle `/verbose on|off` while a run is in-flight, subsequent tool bubbles honor the new setting.
## Related
- Elevated mode docs live in `docs/elevated.md`.
## Heartbeats
- Heartbeat probe body is `HEARTBEAT`. Inline directives in a heartbeat message apply as usual (but avoid changing session defaults from heartbeats).
+17 -1
View File
@@ -21,6 +21,7 @@ Core parameters:
- `yieldMs` (auto-background after timeout, default 10000)
- `background` (immediate background)
- `timeout` (seconds; kills the process if exceeded, default 1800)
- `elevated` (bool; run on host if elevated mode is enabled/allowed)
- Need a real TTY? Use the tmux skill.
Notes:
@@ -47,9 +48,23 @@ Core actions:
- `act` (UI actions: click/type/press/hover/drag/select/fill/resize/wait/evaluate)
- `navigate`, `console`, `pdf`, `upload`, `dialog`
Profile management:
- `profiles` — list all browser profiles with status
- `create-profile` — create new profile with auto-allocated port (or `cdpUrl`)
- `delete-profile` — stop browser, delete user data, remove from config (local only)
- `reset-profile` — kill orphan process on profile's port (local only)
Common parameters:
- `controlUrl` (defaults from config)
- `profile` (optional; defaults to `browser.defaultProfile`)
Notes:
- Requires `browser.enabled=true` in `~/.clawdis/clawdis.json`.
- Uses `browser.controlUrl` unless `controlUrl` is passed explicitly.
- All actions accept optional `profile` parameter for multi-instance support.
- When `profile` is omitted, uses `browser.defaultProfile` (defaults to "clawd").
- Profile names: lowercase alphanumeric + hyphens only (max 64 chars).
- Port range: 18800-18899 (~100 profiles max).
- Remote profiles are attach-only (no start/stop/reset).
- `snapshot` defaults to `ai`; use `aria` for the accessibility tree.
- `act` requires `ref` from `snapshot --format ai`; use `evaluate` for rare CSS selector needs.
- Avoid `act` → `wait` by default; use it only in exceptional cases (no reliable UI state to wait on).
@@ -119,7 +134,8 @@ 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_send` always runs a follow‑up **agent‑to‑agent post step**; reply `ANNOUNCE_SKIP` to suppress the announcement.
- `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.
### `discord`
Send Discord reactions, stickers, or polls.
+1
View File
@@ -51,6 +51,7 @@ Use SSH tunneling or Tailscale to reach the Gateway WS.
- `/model <provider/model>` (or `/models`)
- `/think <off|minimal|low|medium|high>`
- `/verbose <on|off>`
- `/elevated <on|off>`
- `/activation <mention|always>`
- `/deliver <on|off>`
- `/new` or `/reset`
+7 -2
View File
@@ -2,6 +2,9 @@
set -euo pipefail
export DISPLAY=:1
export HOME=/tmp/clawdis-home
export XDG_CONFIG_HOME="${HOME}/.config"
export XDG_CACHE_HOME="${HOME}/.cache"
CDP_PORT="${CLAWDIS_BROWSER_CDP_PORT:-9222}"
VNC_PORT="${CLAWDIS_BROWSER_VNC_PORT:-5900}"
@@ -9,7 +12,7 @@ NOVNC_PORT="${CLAWDIS_BROWSER_NOVNC_PORT:-6080}"
ENABLE_NOVNC="${CLAWDIS_BROWSER_ENABLE_NOVNC:-1}"
HEADLESS="${CLAWDIS_BROWSER_HEADLESS:-0}"
mkdir -p /workspace/.chrome
mkdir -p "${HOME}" "${HOME}/.chrome" "${XDG_CONFIG_HOME}" "${XDG_CACHE_HOME}"
Xvfb :1 -screen 0 1280x800x24 -ac -nolisten tcp &
@@ -25,12 +28,14 @@ fi
CHROME_ARGS+=(
"--remote-debugging-address=0.0.0.0"
"--remote-debugging-port=${CDP_PORT}"
"--user-data-dir=/workspace/.chrome"
"--user-data-dir=${HOME}/.chrome"
"--no-first-run"
"--no-default-browser-check"
"--disable-dev-shm-usage"
"--disable-background-networking"
"--disable-features=TranslateUI"
"--disable-breakpad"
"--disable-crash-reporter"
"--metrics-recording-only"
"--no-sandbox"
)
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_IMAGE="${BASE_IMAGE:-clawdis-sandbox:bookworm-slim}"
TARGET_IMAGE="${TARGET_IMAGE:-clawdis-sandbox-common:bookworm-slim}"
PACKAGES="${PACKAGES:-curl wget jq coreutils grep nodejs npm python3 git ca-certificates golang-go rustc cargo unzip pkg-config libasound2-dev build-essential file}"
INSTALL_PNPM="${INSTALL_PNPM:-1}"
INSTALL_BUN="${INSTALL_BUN:-1}"
BUN_INSTALL_DIR="${BUN_INSTALL_DIR:-/opt/bun}"
INSTALL_BREW="${INSTALL_BREW:-1}"
BREW_INSTALL_DIR="${BREW_INSTALL_DIR:-/home/linuxbrew/.linuxbrew}"
if ! docker image inspect "${BASE_IMAGE}" >/dev/null 2>&1; then
echo "Base image missing: ${BASE_IMAGE}"
echo "Building base image via scripts/sandbox-setup.sh..."
scripts/sandbox-setup.sh
fi
echo "Building ${TARGET_IMAGE} with: ${PACKAGES}"
docker build \
-t "${TARGET_IMAGE}" \
--build-arg INSTALL_PNPM="${INSTALL_PNPM}" \
--build-arg INSTALL_BUN="${INSTALL_BUN}" \
--build-arg BUN_INSTALL_DIR="${BUN_INSTALL_DIR}" \
--build-arg INSTALL_BREW="${INSTALL_BREW}" \
--build-arg BREW_INSTALL_DIR="${BREW_INSTALL_DIR}" \
- <<EOF
FROM ${BASE_IMAGE}
ENV DEBIAN_FRONTEND=noninteractive
ARG INSTALL_PNPM=1
ARG INSTALL_BUN=1
ARG BUN_INSTALL_DIR=/opt/bun
ARG INSTALL_BREW=1
ARG BREW_INSTALL_DIR=/home/linuxbrew/.linuxbrew
ENV BUN_INSTALL=\${BUN_INSTALL_DIR}
ENV HOMEBREW_PREFIX="\${BREW_INSTALL_DIR}"
ENV HOMEBREW_CELLAR="\${BREW_INSTALL_DIR}/Cellar"
ENV HOMEBREW_REPOSITORY="\${BREW_INSTALL_DIR}/Homebrew"
ENV PATH="\${BUN_INSTALL_DIR}/bin:\${BREW_INSTALL_DIR}/bin:\${BREW_INSTALL_DIR}/sbin:\${PATH}"
RUN apt-get update \\
&& apt-get install -y --no-install-recommends ${PACKAGES} \\
&& rm -rf /var/lib/apt/lists/*
RUN if [ "\${INSTALL_PNPM}" = "1" ]; then npm install -g pnpm; fi
RUN if [ "\${INSTALL_BUN}" = "1" ]; then \\
curl -fsSL https://bun.sh/install | bash; \\
ln -sf "\${BUN_INSTALL_DIR}/bin/bun" /usr/local/bin/bun; \\
fi
RUN if [ "\${INSTALL_BREW}" = "1" ]; then \\
if ! id -u linuxbrew >/dev/null 2>&1; then useradd -m -s /bin/bash linuxbrew; fi; \\
mkdir -p "\${BREW_INSTALL_DIR}"; \\
chown -R linuxbrew:linuxbrew "\$(dirname "\${BREW_INSTALL_DIR}")"; \\
su - linuxbrew -c "NONINTERACTIVE=1 CI=1 /bin/bash -c '\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)'"; \\
if [ ! -e "\${BREW_INSTALL_DIR}/Library" ]; then ln -s "\${BREW_INSTALL_DIR}/Homebrew/Library" "\${BREW_INSTALL_DIR}/Library"; fi; \\
if [ ! -x "\${BREW_INSTALL_DIR}/bin/brew" ]; then echo "brew install failed"; exit 1; fi; \\
ln -sf "\${BREW_INSTALL_DIR}/bin/brew" /usr/local/bin/brew; \\
fi
EOF
cat <<NOTE
Built ${TARGET_IMAGE}.
To use it, set agent.sandbox.docker.image to "${TARGET_IMAGE}" and restart.
If you want a clean re-create, remove old sandbox containers:
docker rm -f \$(docker ps -aq --filter label=clawdis.sandbox=1)
NOTE
+53
View File
@@ -0,0 +1,53 @@
---
name: clawdhub
description: Use the ClawdHub CLI to search, install, update, and publish agent skills from clawdhub.com. Use when you need to fetch new skills on the fly, sync installed skills to latest or a specific version, or publish new/updated skill folders with the npm-installed clawdhub CLI.
metadata: {"clawdis":{"requires":{"bins":["clawdhub"]},"install":[{"id":"node","kind":"node","package":"clawdhub","bins":["clawdhub"],"label":"Install ClawdHub CLI (npm)"}]}}
---
# ClawdHub CLI
Install
```bash
npm i -g clawdhub
```
Auth (publish)
```bash
clawdhub login
clawdhub whoami
```
Search
```bash
clawdhub search "postgres backups"
```
Install
```bash
clawdhub install my-skill
clawdhub install my-skill --version 1.2.3
```
Update (hash-based match + upgrade)
```bash
clawdhub update my-skill
clawdhub update my-skill --version 1.2.3
clawdhub update --all
clawdhub update my-skill --force
clawdhub update --all --no-input --force
```
List
```bash
clawdhub list
```
Publish
```bash
clawdhub publish ./my-skill --slug my-skill --name "My Skill" --version 1.2.0 --changelog "Fixes + docs"
```
Notes
- Default registry: https://clawdhub.com (override with CLAWDHUB_REGISTRY or --registry)
- Default workdir: cwd; install dir: ./skills (override with --workdir / --dir)
- Update command hashes local files, resolves matching version, and upgrades to latest unless --version is set
+36 -1
View File
@@ -1,6 +1,6 @@
---
name: discord
description: Use when you need to control Discord from Clawdis via the discord tool: send messages, react, post stickers, run polls, manage threads/pins/search, fetch permissions or member/role/channel info, or handle moderation actions in Discord DMs or channels.
description: Use when you need to control Discord from Clawdis via the discord tool: send messages, react, post or upload stickers, upload emojis, run polls, manage threads/pins/search, fetch permissions or member/role/channel info, or handle moderation actions in Discord DMs or channels.
---
# Discord Actions
@@ -15,6 +15,8 @@ Use `discord` to manage messages, reactions, threads, polls, and moderation. You
- For stickers/polls/sendMessage: a `to` target (`channel:<id>` or `user:<id>`). Optional `content` text.
- Polls also need a `question` plus 2–10 `answers`.
- For media: `mediaUrl` with `file:///path` for local files or `https://...` for remote.
- For emoji uploads: `guildId`, `name`, `mediaUrl`, optional `roleIds` (limit 256KB, PNG/JPG/GIF).
- For sticker uploads: `guildId`, `name`, `description`, `tags`, `mediaUrl` (limit 512KB, PNG/APNG/Lottie JSON).
Message context lines include `discord message id` and `channel` fields you can reuse directly.
@@ -58,6 +60,37 @@ Message context lines include `discord message id` and `channel` fields you can
- Up to 3 sticker IDs per message.
- `to` can be `user:<id>` for DMs.
### Upload a custom emoji
```json
{
"action": "emojiUpload",
"guildId": "999",
"name": "party_blob",
"mediaUrl": "file:///tmp/party.png",
"roleIds": ["222"]
}
```
- Emoji images must be PNG/JPG/GIF and <= 256KB.
- `roleIds` is optional; omit to make the emoji available to everyone.
### Upload a sticker
```json
{
"action": "stickerUpload",
"guildId": "999",
"name": "clawdis_wave",
"description": "Clawdis waving hello",
"tags": "👋",
"mediaUrl": "file:///tmp/wave.png"
}
```
- Stickers require `name`, `description`, and `tags`.
- Uploads must be PNG/APNG/Lottie JSON and <= 512KB.
### Create a poll
```json
@@ -88,6 +121,7 @@ Message context lines include `discord message id` and `channel` fields you can
- React with ✅/⚠️ to mark status updates.
- Post a quick poll for release decisions or meeting times.
- Send celebratory stickers after successful deploys.
- Upload new emojis/stickers for release moments.
- Run weekly “priority check” polls in team channels.
- DM stickers as acknowledgements when a user’s request is completed.
@@ -96,6 +130,7 @@ Message context lines include `discord message id` and `channel` fields you can
Use `discord.actions.*` to disable action groups:
- `reactions` (react + reactions list + emojiList)
- `stickers`, `polls`, `permissions`, `messages`, `threads`, `pins`, `search`
- `emojiUploads`, `stickerUploads`
- `memberInfo`, `roleInfo`, `channelInfo`, `voiceStatus`, `events`
- `roles` (role add/remove, default `false`)
- `moderation` (timeout/kick/ban, default `false`)
+156
View File
@@ -0,0 +1,156 @@
---
name: notion
description: Notion API for creating and managing pages, databases, and blocks.
homepage: https://developers.notion.com
metadata: {"clawdis":{"emoji":"📝"}}
---
# notion
Use the Notion API to create/read/update pages, data sources (databases), and blocks.
## Setup
1. Create an integration at https://notion.so/my-integrations
2. Copy the API key (starts with `ntn_` or `secret_`)
3. Store it:
```bash
mkdir -p ~/.config/notion
echo "ntn_your_key_here" > ~/.config/notion/api_key
```
4. Share target pages/databases with your integration (click "..." → "Connect to" → your integration name)
## API Basics
All requests need:
```bash
NOTION_KEY=$(cat ~/.config/notion/api_key)
curl -X GET "https://api.notion.com/v1/..." \
-H "Authorization: Bearer $NOTION_KEY" \
-H "Notion-Version: 2025-09-03" \
-H "Content-Type: application/json"
```
> **Note:** The `Notion-Version` header is required. This skill uses `2025-09-03` (latest). In this version, databases are called "data sources" in the API.
## Common Operations
**Search for pages and data sources:**
```bash
curl -X POST "https://api.notion.com/v1/search" \
-H "Authorization: Bearer $NOTION_KEY" \
-H "Notion-Version: 2025-09-03" \
-H "Content-Type: application/json" \
-d '{"query": "page title"}'
```
**Get page:**
```bash
curl "https://api.notion.com/v1/pages/{page_id}" \
-H "Authorization: Bearer $NOTION_KEY" \
-H "Notion-Version: 2025-09-03"
```
**Get page content (blocks):**
```bash
curl "https://api.notion.com/v1/blocks/{page_id}/children" \
-H "Authorization: Bearer $NOTION_KEY" \
-H "Notion-Version: 2025-09-03"
```
**Create page in a data source:**
```bash
curl -X POST "https://api.notion.com/v1/pages" \
-H "Authorization: Bearer $NOTION_KEY" \
-H "Notion-Version: 2025-09-03" \
-H "Content-Type: application/json" \
-d '{
"parent": {"database_id": "xxx"},
"properties": {
"Name": {"title": [{"text": {"content": "New Item"}}]},
"Status": {"select": {"name": "Todo"}}
}
}'
```
**Query a data source (database):**
```bash
curl -X POST "https://api.notion.com/v1/data_sources/{data_source_id}/query" \
-H "Authorization: Bearer $NOTION_KEY" \
-H "Notion-Version: 2025-09-03" \
-H "Content-Type: application/json" \
-d '{
"filter": {"property": "Status", "select": {"equals": "Active"}},
"sorts": [{"property": "Date", "direction": "descending"}]
}'
```
**Create a data source (database):**
```bash
curl -X POST "https://api.notion.com/v1/data_sources" \
-H "Authorization: Bearer $NOTION_KEY" \
-H "Notion-Version: 2025-09-03" \
-H "Content-Type: application/json" \
-d '{
"parent": {"page_id": "xxx"},
"title": [{"text": {"content": "My Database"}}],
"properties": {
"Name": {"title": {}},
"Status": {"select": {"options": [{"name": "Todo"}, {"name": "Done"}]}},
"Date": {"date": {}}
}
}'
```
**Update page properties:**
```bash
curl -X PATCH "https://api.notion.com/v1/pages/{page_id}" \
-H "Authorization: Bearer $NOTION_KEY" \
-H "Notion-Version: 2025-09-03" \
-H "Content-Type: application/json" \
-d '{"properties": {"Status": {"select": {"name": "Done"}}}}'
```
**Add blocks to page:**
```bash
curl -X PATCH "https://api.notion.com/v1/blocks/{page_id}/children" \
-H "Authorization: Bearer $NOTION_KEY" \
-H "Notion-Version: 2025-09-03" \
-H "Content-Type: application/json" \
-d '{
"children": [
{"object": "block", "type": "paragraph", "paragraph": {"rich_text": [{"text": {"content": "Hello"}}]}}
]
}'
```
## Property Types
Common property formats for database items:
- **Title:** `{"title": [{"text": {"content": "..."}}]}`
- **Rich text:** `{"rich_text": [{"text": {"content": "..."}}]}`
- **Select:** `{"select": {"name": "Option"}}`
- **Multi-select:** `{"multi_select": [{"name": "A"}, {"name": "B"}]}`
- **Date:** `{"date": {"start": "2024-01-15", "end": "2024-01-16"}}`
- **Checkbox:** `{"checkbox": true}`
- **Number:** `{"number": 42}`
- **URL:** `{"url": "https://..."}`
- **Email:** `{"email": "a@b.com"}`
- **Relation:** `{"relation": [{"id": "page_id"}]}`
## Key Differences in 2025-09-03
- **Databases → Data Sources:** Use `/data_sources/` endpoints for queries and retrieval
- **Two IDs:** Each database now has both a `database_id` and a `data_source_id`
- Use `database_id` when creating pages (`parent: {"database_id": "..."}`)
- Use `data_source_id` when querying (`POST /v1/data_sources/{id}/query`)
- **Search results:** Databases return as `"object": "data_source"` with their `data_source_id`
- **Parent in responses:** Pages show `parent.data_source_id` alongside `parent.database_id`
- **Finding the data_source_id:** Search for the database, or call `GET /v1/data_sources/{data_source_id}`
## Notes
- Page/database IDs are UUIDs (with or without dashes)
- The API cannot set database view filters — that's UI-only
- Rate limit: ~3 requests/second average
- Use `is_inline: true` when creating data sources to embed them in pages
+22 -11
View File
@@ -1,31 +1,42 @@
---
name: wacli
description: WhatsApp CLI for sync, search, and sending messages.
description: Send WhatsApp messages to other people or search/sync WhatsApp history via the wacli CLI (not for normal user chats).
homepage: https://wacli.sh
metadata: {"clawdis":{"emoji":"📱","requires":{"bins":["wacli"]},"install":[{"id":"go","kind":"go","module":"github.com/steipete/wacli/cmd/wacli@latest","bins":["wacli"],"label":"Install wacli (go)"}]}}
metadata: {"clawdis":{"emoji":"📱","requires":{"bins":["wacli"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/wacli","bins":["wacli"],"label":"Install wacli (brew)"},{"id":"go","kind":"go","module":"github.com/steipete/wacli/cmd/wacli@latest","bins":["wacli"],"label":"Install wacli (go)"}]}}
---
# wacli
Use `wacli` for WhatsApp sync/search/send via WhatsApp Web protocol.
Normal operation: the user chats with you via Telegram or WhatsApp and Clawdis handles it automatically.
Use `wacli` only when you need to talk to other people on WhatsApp (or to debug sync/search).
Use `wacli` only when the user explicitly asks you to message someone else on WhatsApp or when they ask to sync/search WhatsApp history.
Do NOT use `wacli` for normal user chats; Clawdis routes WhatsApp conversations automatically.
If the user is chatting with you on WhatsApp, you should not reach for this tool unless they ask you to contact a third party.
Safety
- Require explicit recipient + message text.
- Confirm recipient + message before sending.
- If anything is ambiguous, ask a clarifying question.
Auth + sync
- `wacli auth` (QR login + initial sync)
- `wacli sync --follow` (continuous sync)
- `wacli doctor`
Search + history
- `wacli messages search "query"`
- `wacli history backfill --chat <jid> --requests 5 --count 50`
Find chats + messages
- `wacli chats list --limit 20 --query "name or number"`
- `wacli messages search "query" --limit 20 --chat <jid>`
- `wacli messages search "invoice" --after 2025-01-01 --before 2025-12-31`
History backfill
- `wacli history backfill --chat <jid> --requests 2 --count 50`
Send
- Text: `wacli send text --to 1234567890 --message "hello"`
- File: `wacli send file --to 1234567890 --file /path/pic.jpg --caption "hi"`
- Text: `wacli send text --to "+14155551212" --message "Hello! Are you free at 3pm?"`
- Group: `wacli send text --to "1234567890-123456789@g.us" --message "Running 5 min late."`
- File: `wacli send file --to "+14155551212" --file /path/agenda.pdf --caption "Agenda"`
Notes
- Store dir: `~/.wacli` (override with `--store`).
- Use `--json` for machine-readable output when parsing.
- Backfill requires your phone online; results are best-effort.
- WhatsApp CLI is not needed for routine user chats; it’s for messaging other people.
- Confirm recipient + message before sending.
- JIDs: direct chats look like `<number>@s.whatsapp.net`; groups look like `<id>@g.us` (use `wacli chats list` to find).
+13
View File
@@ -119,6 +119,19 @@ describe("bash tool backgrounding", () => {
expect(status).toBe("failed");
});
it("rejects elevated requests when not allowed", async () => {
const customBash = createBashTool({
elevated: { enabled: true, allowed: false, defaultLevel: "off" },
});
await expect(
customBash.execute("call1", {
command: "echo hi",
elevated: true,
}),
).rejects.toThrow("elevated is not available right now.");
});
it("logs line-based slices and defaults to last lines", async () => {
const result = await bashTool.execute("call1", {
command:
+32 -1
View File
@@ -7,6 +7,7 @@ import path from "node:path";
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox";
import { logInfo } from "../logger.js";
import {
addSession,
appendOutput,
@@ -53,6 +54,7 @@ export type BashToolDefaults = {
backgroundMs?: number;
timeoutSec?: number;
sandbox?: BashSandboxConfig;
elevated?: BashElevatedDefaults;
};
export type ProcessToolDefaults = {
@@ -66,6 +68,12 @@ export type BashSandboxConfig = {
env?: Record<string, string>;
};
export type BashElevatedDefaults = {
enabled: boolean;
allowed: boolean;
defaultLevel: "on" | "off";
};
const bashSchema = Type.Object({
command: Type.String({ description: "Bash command to execute" }),
workdir: Type.Optional(
@@ -85,6 +93,11 @@ const bashSchema = Type.Object({
description: "Timeout in seconds (optional, kills process on expiry)",
}),
),
elevated: Type.Optional(
Type.Boolean({
description: "Run on the host with elevated permissions (if allowed)",
}),
),
});
export type BashToolDetails =
@@ -131,6 +144,7 @@ export function createBashTool(
yieldMs?: number;
background?: boolean;
timeout?: number;
elevated?: boolean;
};
if (!params.command) {
@@ -149,7 +163,24 @@ export function createBashTool(
const startedAt = Date.now();
const sessionId = randomUUID();
const warnings: string[] = [];
const sandbox = defaults?.sandbox;
const elevatedDefaults = defaults?.elevated;
const elevatedRequested =
typeof params.elevated === "boolean"
? params.elevated
: elevatedDefaults?.defaultLevel === "on";
if (elevatedRequested) {
if (!elevatedDefaults?.enabled || !elevatedDefaults.allowed) {
throw new Error("elevated is not available right now.");
}
logInfo(
`bash: elevated command (${sessionId.slice(0, 8)}) ${truncateMiddle(
params.command,
120,
)}`,
);
}
const sandbox = elevatedRequested ? undefined : defaults?.sandbox;
const rawWorkdir = params.workdir?.trim() || process.cwd();
let workdir = rawWorkdir;
let containerWorkdir = sandbox?.containerWorkdir;
+149 -18
View File
@@ -7,7 +7,11 @@ vi.mock("../gateway/call.js", () => ({
vi.mock("../config/config.js", () => ({
loadConfig: () => ({
session: { mainKey: "main", scope: "per-sender" },
session: {
mainKey: "main",
scope: "per-sender",
agentToAgent: { maxPingPongTurns: 2 },
},
}),
resolveGatewayPort: () => 18789,
}));
@@ -127,18 +131,28 @@ describe("sessions tools", () => {
let agentCallCount = 0;
let _historyCallCount = 0;
let sendCallCount = 0;
let waitRunId: string | undefined;
let nextHistoryIsWaitReply = false;
let lastWaitedRunId: string | undefined;
const replyByRunId = new Map<string, string>();
const requesterKey = "discord:group:req";
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown };
calls.push(request);
if (request.method === "agent") {
agentCallCount += 1;
const runId = `run-${agentCallCount}`;
const params = request.params as { message?: string } | undefined;
if (params?.message === "wait") {
waitRunId = runId;
const params = request.params as
| { message?: string; sessionKey?: string }
| undefined;
const message = params?.message ?? "";
let reply = "REPLY_SKIP";
if (message === "ping" || message === "wait") {
reply = "done";
} else if (message === "Agent-to-agent announce step.") {
reply = "ANNOUNCE_SKIP";
} else if (params?.sessionKey === requesterKey) {
reply = "pong";
}
replyByRunId.set(runId, reply);
return {
runId,
status: "accepted",
@@ -147,15 +161,13 @@ describe("sessions tools", () => {
}
if (request.method === "agent.wait") {
const params = request.params as { runId?: string } | undefined;
if (params?.runId && params.runId === waitRunId) {
nextHistoryIsWaitReply = true;
}
lastWaitedRunId = params?.runId;
return { runId: params?.runId ?? "run-1", status: "ok" };
}
if (request.method === "chat.history") {
_historyCallCount += 1;
const text = nextHistoryIsWaitReply ? "done" : "ANNOUNCE_SKIP";
nextHistoryIsWaitReply = false;
const text =
(lastWaitedRunId && replyByRunId.get(lastWaitedRunId)) ?? "";
return {
messages: [
{
@@ -178,9 +190,10 @@ describe("sessions tools", () => {
return {};
});
const tool = createClawdisTools().find(
(candidate) => candidate.name === "sessions_send",
);
const tool = createClawdisTools({
agentSessionKey: requesterKey,
agentSurface: "discord",
}).find((candidate) => candidate.name === "sessions_send");
expect(tool).toBeDefined();
if (!tool) throw new Error("missing sessions_send tool");
@@ -191,6 +204,7 @@ describe("sessions tools", () => {
});
expect(fire.details).toMatchObject({ status: "accepted", runId: "run-1" });
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
const waitPromise = tool.execute("call6", {
sessionKey: "main",
@@ -204,13 +218,14 @@ describe("sessions tools", () => {
});
expect(typeof (waited.details as { runId?: string }).runId).toBe("string");
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
const agentCalls = calls.filter((call) => call.method === "agent");
const waitCalls = calls.filter((call) => call.method === "agent.wait");
const historyOnlyCalls = calls.filter(
(call) => call.method === "chat.history",
);
expect(agentCalls).toHaveLength(4);
expect(agentCalls).toHaveLength(8);
for (const call of agentCalls) {
expect(call.params).toMatchObject({ lane: "nested" });
}
@@ -231,11 +246,21 @@ describe("sessions tools", () => {
?.extraSystemPrompt === "string" &&
(
call.params as { extraSystemPrompt?: string }
)?.extraSystemPrompt?.includes("Agent-to-agent post step"),
)?.extraSystemPrompt?.includes("Agent-to-agent reply step"),
),
).toBe(true);
expect(waitCalls).toHaveLength(3);
expect(historyOnlyCalls).toHaveLength(3);
expect(
agentCalls.some(
(call) =>
typeof (call.params as { extraSystemPrompt?: string })
?.extraSystemPrompt === "string" &&
(
call.params as { extraSystemPrompt?: string }
)?.extraSystemPrompt?.includes("Agent-to-agent announce step"),
),
).toBe(true);
expect(waitCalls).toHaveLength(8);
expect(historyOnlyCalls).toHaveLength(8);
expect(
waitCalls.some(
(call) =>
@@ -244,4 +269,110 @@ describe("sessions tools", () => {
).toBe(true);
expect(sendCallCount).toBe(0);
});
it("sessions_send runs ping-pong then announces", async () => {
callGatewayMock.mockReset();
const calls: Array<{ method?: string; params?: unknown }> = [];
let agentCallCount = 0;
let lastWaitedRunId: string | undefined;
const replyByRunId = new Map<string, string>();
const requesterKey = "discord:group:req";
const targetKey = "discord:group:target";
let sendParams: { to?: string; provider?: string; message?: string } = {};
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown };
calls.push(request);
if (request.method === "agent") {
agentCallCount += 1;
const runId = `run-${agentCallCount}`;
const params = request.params as
| {
message?: string;
sessionKey?: string;
extraSystemPrompt?: string;
}
| undefined;
let reply = "initial";
if (params?.extraSystemPrompt?.includes("Agent-to-agent reply step")) {
reply = params.sessionKey === requesterKey ? "pong-1" : "pong-2";
}
if (
params?.extraSystemPrompt?.includes("Agent-to-agent announce step")
) {
reply = "announce now";
}
replyByRunId.set(runId, reply);
return {
runId,
status: "accepted",
acceptedAt: 2000 + agentCallCount,
};
}
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 }],
timestamp: 20,
},
],
};
}
if (request.method === "send") {
const params = request.params as
| { to?: string; provider?: string; message?: string }
| undefined;
sendParams = {
to: params?.to,
provider: params?.provider,
message: params?.message,
};
return { messageId: "m-announce" };
}
return {};
});
const tool = createClawdisTools({
agentSessionKey: requesterKey,
agentSurface: "discord",
}).find((candidate) => candidate.name === "sessions_send");
expect(tool).toBeDefined();
if (!tool) throw new Error("missing sessions_send tool");
const waited = await tool.execute("call7", {
sessionKey: targetKey,
message: "ping",
timeoutSeconds: 1,
});
expect(waited.details).toMatchObject({
status: "ok",
reply: "initial",
});
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
const replySteps = calls.filter(
(call) =>
call.method === "agent" &&
typeof (call.params as { extraSystemPrompt?: string })
?.extraSystemPrompt === "string" &&
(
call.params as { extraSystemPrompt?: string }
)?.extraSystemPrompt?.includes("Agent-to-agent reply step"),
);
expect(replySteps).toHaveLength(2);
expect(sendParams).toMatchObject({
to: "channel:target",
provider: "discord",
message: "announce now",
});
});
});
File diff suppressed because it is too large. Load diff
+6 -1
View File
@@ -34,6 +34,7 @@ import {
} from "../process/command-queue.js";
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
import { resolveClawdisAgentDir } from "./agent-paths.js";
import type { BashElevatedDefaults } from "./bash-tools.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
import { ensureClawdisModelsJson } from "./models-config.js";
import {
@@ -390,6 +391,7 @@ export async function runEmbeddedPiAgent(params: {
model?: string;
thinkLevel?: ThinkLevel;
verboseLevel?: VerboseLevel;
bashElevated?: BashElevatedDefaults;
timeoutMs: number;
runId: string;
abortSignal?: AbortSignal;
@@ -495,7 +497,10 @@ export async function runEmbeddedPiAgent(params: {
const contextFiles = buildBootstrapContextFiles(bootstrapFiles);
const promptSkills = resolvePromptSkills(skillsSnapshot, skillEntries);
const tools = createClawdisCodingTools({
bash: params.config?.agent?.bash,
bash: {
...params.config?.agent?.bash,
elevated: params.bashElevated,
},
sandbox,
surface: params.surface,
sessionKey: params.sessionKey ?? params.sessionId,
+13 -5
View File
@@ -9,7 +9,10 @@ import {
startBrowserBridgeServer,
stopBrowserBridgeServer,
} from "../browser/bridge-server.js";
import type { ResolvedBrowserConfig } from "../browser/config.js";
import {
type ResolvedBrowserConfig,
resolveProfile,
} from "../browser/config.js";
import { DEFAULT_CLAWD_BROWSER_COLOR } from "../browser/constants.js";
import type { ClawdisConfig } from "../config/config.js";
import { STATE_DIR_CLAWDIS } from "../config/config.js";
@@ -508,21 +511,23 @@ function buildSandboxBrowserResolvedConfig(params: {
const controlHost = "127.0.0.1";
const controlUrl = `http://${controlHost}:${params.controlPort}`;
const cdpHost = "127.0.0.1";
const cdpUrl = `http://${cdpHost}:${params.cdpPort}`;
return {
enabled: true,
controlUrl,
controlHost,
controlPort: params.controlPort,
cdpUrl,
cdpProtocol: "http",
cdpHost,
cdpPort: params.cdpPort,
cdpIsLoopback: true,
color: DEFAULT_CLAWD_BROWSER_COLOR,
executablePath: undefined,
headless: params.headless,
noSandbox: false,
attachOnly: true,
defaultProfile: "clawd",
profiles: {
clawd: { cdpPort: params.cdpPort, color: DEFAULT_CLAWD_BROWSER_COLOR },
},
};
}
@@ -600,10 +605,13 @@ async function ensureSandboxBrowser(params: {
: null;
const existing = BROWSER_BRIDGES.get(params.sessionKey);
const existingProfile = existing
? resolveProfile(existing.bridge.state.resolved, "clawd")
: null;
const shouldReuse =
existing &&
existing.containerName === containerName &&
existing.bridge.state.resolved.cdpPort === mappedCdp;
existingProfile?.cdpPort === mappedCdp;
if (existing && !shouldReuse) {
await stopBrowserBridgeServer(existing.bridge.server).catch(
() => undefined,
+2
View File
@@ -196,6 +196,8 @@
"memberInfo": { "label": "member", "detailKeys": ["guildId", "userId"] },
"roleInfo": { "label": "roles", "detailKeys": ["guildId"] },
"emojiList": { "label": "emoji list", "detailKeys": ["guildId"] },
"emojiUpload": { "label": "emoji upload", "detailKeys": ["guildId", "name"] },
"stickerUpload": { "label": "sticker upload", "detailKeys": ["guildId", "name"] },
"roleAdd": { "label": "role add", "detailKeys": ["guildId", "userId", "roleId"] },
"roleRemove": { "label": "role remove", "detailKeys": ["guildId", "userId", "roleId"] },
"channelInfo": { "label": "channel", "detailKeys": ["channelId"] },
+397
View File
@@ -0,0 +1,397 @@
import { Type } from "@sinclair/typebox";
import {
browserCloseTab,
browserFocusTab,
browserOpenTab,
browserSnapshot,
browserStart,
browserStatus,
browserStop,
browserTabs,
} from "../../browser/client.js";
import {
browserAct,
browserArmDialog,
browserArmFileChooser,
browserConsoleMessages,
browserNavigate,
browserPdfSave,
browserScreenshotAction,
} from "../../browser/client-actions.js";
import { resolveBrowserConfig } from "../../browser/config.js";
import { loadConfig } from "../../config/config.js";
import {
type AnyAgentTool,
imageResultFromFile,
jsonResult,
readStringParam,
} from "./common.js";
const BrowserActSchema = Type.Union([
Type.Object({
kind: Type.Literal("click"),
ref: Type.String(),
targetId: Type.Optional(Type.String()),
doubleClick: Type.Optional(Type.Boolean()),
button: Type.Optional(Type.String()),
modifiers: Type.Optional(Type.Array(Type.String())),
}),
Type.Object({
kind: Type.Literal("type"),
ref: Type.String(),
text: Type.String(),
targetId: Type.Optional(Type.String()),
submit: Type.Optional(Type.Boolean()),
slowly: Type.Optional(Type.Boolean()),
}),
Type.Object({
kind: Type.Literal("press"),
key: Type.String(),
targetId: Type.Optional(Type.String()),
}),
Type.Object({
kind: Type.Literal("hover"),
ref: Type.String(),
targetId: Type.Optional(Type.String()),
}),
Type.Object({
kind: Type.Literal("drag"),
startRef: Type.String(),
endRef: Type.String(),
targetId: Type.Optional(Type.String()),
}),
Type.Object({
kind: Type.Literal("select"),
ref: Type.String(),
values: Type.Array(Type.String()),
targetId: Type.Optional(Type.String()),
}),
Type.Object({
kind: Type.Literal("fill"),
fields: Type.Array(Type.Record(Type.String(), Type.Unknown())),
targetId: Type.Optional(Type.String()),
}),
Type.Object({
kind: Type.Literal("resize"),
width: Type.Number(),
height: Type.Number(),
targetId: Type.Optional(Type.String()),
}),
Type.Object({
kind: Type.Literal("wait"),
timeMs: Type.Optional(Type.Number()),
text: Type.Optional(Type.String()),
textGone: Type.Optional(Type.String()),
targetId: Type.Optional(Type.String()),
}),
Type.Object({
kind: Type.Literal("evaluate"),
fn: Type.String(),
ref: Type.Optional(Type.String()),
targetId: Type.Optional(Type.String()),
}),
Type.Object({
kind: Type.Literal("close"),
targetId: Type.Optional(Type.String()),
}),
]);
const BrowserToolSchema = Type.Union([
Type.Object({
action: Type.Literal("status"),
controlUrl: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("start"),
controlUrl: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("stop"),
controlUrl: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("tabs"),
controlUrl: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("open"),
controlUrl: Type.Optional(Type.String()),
targetUrl: Type.String(),
}),
Type.Object({
action: Type.Literal("focus"),
controlUrl: Type.Optional(Type.String()),
targetId: Type.String(),
}),
Type.Object({
action: Type.Literal("close"),
controlUrl: Type.Optional(Type.String()),
targetId: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("snapshot"),
controlUrl: Type.Optional(Type.String()),
format: Type.Optional(
Type.Union([Type.Literal("aria"), Type.Literal("ai")]),
),
targetId: Type.Optional(Type.String()),
limit: Type.Optional(Type.Number()),
}),
Type.Object({
action: Type.Literal("screenshot"),
controlUrl: Type.Optional(Type.String()),
targetId: Type.Optional(Type.String()),
fullPage: Type.Optional(Type.Boolean()),
ref: Type.Optional(Type.String()),
element: Type.Optional(Type.String()),
type: Type.Optional(
Type.Union([Type.Literal("png"), Type.Literal("jpeg")]),
),
}),
Type.Object({
action: Type.Literal("navigate"),
controlUrl: Type.Optional(Type.String()),
targetUrl: Type.String(),
targetId: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("console"),
controlUrl: Type.Optional(Type.String()),
level: Type.Optional(Type.String()),
targetId: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("pdf"),
controlUrl: Type.Optional(Type.String()),
targetId: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("upload"),
controlUrl: Type.Optional(Type.String()),
paths: Type.Array(Type.String()),
ref: Type.Optional(Type.String()),
inputRef: Type.Optional(Type.String()),
element: Type.Optional(Type.String()),
targetId: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
}),
Type.Object({
action: Type.Literal("dialog"),
controlUrl: Type.Optional(Type.String()),
accept: Type.Boolean(),
promptText: Type.Optional(Type.String()),
targetId: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
}),
Type.Object({
action: Type.Literal("act"),
controlUrl: Type.Optional(Type.String()),
request: BrowserActSchema,
}),
]);
function resolveBrowserBaseUrl(controlUrl?: string) {
const cfg = loadConfig();
const resolved = resolveBrowserConfig(cfg.browser);
if (!resolved.enabled && !controlUrl?.trim()) {
throw new Error(
"Browser control is disabled. Set browser.enabled=true in ~/.clawdis/clawdis.json.",
);
}
const url = controlUrl?.trim() ? controlUrl.trim() : resolved.controlUrl;
return url.replace(/\/$/, "");
}
export function createBrowserTool(opts?: {
defaultControlUrl?: string;
}): AnyAgentTool {
return {
label: "Browser",
name: "browser",
description:
"Control clawd's dedicated browser (status/start/stop/tabs/open/snapshot/screenshot/actions). Use snapshot+act for UI automation. Avoid act:wait by default; use only in exceptional cases when no reliable UI state exists.",
parameters: BrowserToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const action = readStringParam(params, "action", { required: true });
const controlUrl = readStringParam(params, "controlUrl");
const baseUrl = resolveBrowserBaseUrl(
controlUrl ?? opts?.defaultControlUrl,
);
switch (action) {
case "status":
return jsonResult(await browserStatus(baseUrl));
case "start":
await browserStart(baseUrl);
return jsonResult(await browserStatus(baseUrl));
case "stop":
await browserStop(baseUrl);
return jsonResult(await browserStatus(baseUrl));
case "tabs":
return jsonResult({ tabs: await browserTabs(baseUrl) });
case "open": {
const targetUrl = readStringParam(params, "targetUrl", {
required: true,
});
return jsonResult(await browserOpenTab(baseUrl, targetUrl));
}
case "focus": {
const targetId = readStringParam(params, "targetId", {
required: true,
});
await browserFocusTab(baseUrl, targetId);
return jsonResult({ ok: true });
}
case "close": {
const targetId = readStringParam(params, "targetId");
if (targetId) await browserCloseTab(baseUrl, targetId);
else await browserAct(baseUrl, { kind: "close" });
return jsonResult({ ok: true });
}
case "snapshot": {
const format =
params.format === "ai" || params.format === "aria"
? (params.format as "ai" | "aria")
: "ai";
const targetId =
typeof params.targetId === "string"
? params.targetId.trim()
: undefined;
const limit =
typeof params.limit === "number" && Number.isFinite(params.limit)
? params.limit
: undefined;
const snapshot = await browserSnapshot(baseUrl, {
format,
targetId,
limit,
});
if (snapshot.format === "ai") {
return {
content: [{ type: "text", text: snapshot.snapshot }],
details: snapshot,
};
}
return jsonResult(snapshot);
}
case "screenshot": {
const targetId = readStringParam(params, "targetId");
const fullPage = Boolean(params.fullPage);
const ref = readStringParam(params, "ref");
const element = readStringParam(params, "element");
const type = params.type === "jpeg" ? "jpeg" : "png";
const result = await browserScreenshotAction(baseUrl, {
targetId,
fullPage,
ref,
element,
type,
});
return await imageResultFromFile({
label: "browser:screenshot",
path: result.path,
details: result,
});
}
case "navigate": {
const targetUrl = readStringParam(params, "targetUrl", {
required: true,
});
const targetId = readStringParam(params, "targetId");
return jsonResult(
await browserNavigate(baseUrl, { url: targetUrl, targetId }),
);
}
case "console": {
const level =
typeof params.level === "string" ? params.level.trim() : undefined;
const targetId =
typeof params.targetId === "string"
? params.targetId.trim()
: undefined;
return jsonResult(
await browserConsoleMessages(baseUrl, { level, targetId }),
);
}
case "pdf": {
const targetId =
typeof params.targetId === "string"
? params.targetId.trim()
: undefined;
const result = await browserPdfSave(baseUrl, { targetId });
return {
content: [{ type: "text", text: `FILE:${result.path}` }],
details: result,
};
}
case "upload": {
const paths = Array.isArray(params.paths)
? params.paths.map((p) => String(p))
: [];
if (paths.length === 0) throw new Error("paths required");
const ref = readStringParam(params, "ref");
const inputRef = readStringParam(params, "inputRef");
const element = readStringParam(params, "element");
const targetId =
typeof params.targetId === "string"
? params.targetId.trim()
: undefined;
const timeoutMs =
typeof params.timeoutMs === "number" &&
Number.isFinite(params.timeoutMs)
? params.timeoutMs
: undefined;
return jsonResult(
await browserArmFileChooser(baseUrl, {
paths,
ref,
inputRef,
element,
targetId,
timeoutMs,
}),
);
}
case "dialog": {
const accept = Boolean(params.accept);
const promptText =
typeof params.promptText === "string"
? params.promptText
: undefined;
const targetId =
typeof params.targetId === "string"
? params.targetId.trim()
: undefined;
const timeoutMs =
typeof params.timeoutMs === "number" &&
Number.isFinite(params.timeoutMs)
? params.timeoutMs
: undefined;
return jsonResult(
await browserArmDialog(baseUrl, {
accept,
promptText,
targetId,
timeoutMs,
}),
);
}
case "act": {
const request = params.request as Record<string, unknown> | undefined;
if (!request || typeof request !== "object") {
throw new Error("request required");
}
const result = await browserAct(
baseUrl,
request as Parameters<typeof browserAct>[1],
);
return jsonResult(result);
}
default:
throw new Error(`Unknown action: ${action}`);
}
},
};
}
+228
View File
@@ -0,0 +1,228 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import { Type } from "@sinclair/typebox";
import { writeBase64ToFile } from "../../cli/nodes-camera.js";
import {
canvasSnapshotTempPath,
parseCanvasSnapshotPayload,
} from "../../cli/nodes-canvas.js";
import { imageMimeFromFormat } from "../../media/mime.js";
import {
type AnyAgentTool,
imageResult,
jsonResult,
readStringParam,
} from "./common.js";
import { callGatewayTool, type GatewayCallOptions } from "./gateway.js";
import { resolveNodeId } from "./nodes-utils.js";
const CanvasToolSchema = Type.Union([
Type.Object({
action: Type.Literal("present"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.Optional(Type.String()),
target: Type.Optional(Type.String()),
x: Type.Optional(Type.Number()),
y: Type.Optional(Type.Number()),
width: Type.Optional(Type.Number()),
height: Type.Optional(Type.Number()),
}),
Type.Object({
action: Type.Literal("hide"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("navigate"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.Optional(Type.String()),
url: Type.String(),
}),
Type.Object({
action: Type.Literal("eval"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.Optional(Type.String()),
javaScript: Type.String(),
}),
Type.Object({
action: Type.Literal("snapshot"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.Optional(Type.String()),
format: Type.Optional(
Type.Union([
Type.Literal("png"),
Type.Literal("jpg"),
Type.Literal("jpeg"),
]),
),
maxWidth: Type.Optional(Type.Number()),
quality: Type.Optional(Type.Number()),
delayMs: Type.Optional(Type.Number()),
}),
Type.Object({
action: Type.Literal("a2ui_push"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.Optional(Type.String()),
jsonl: Type.Optional(Type.String()),
jsonlPath: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("a2ui_reset"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.Optional(Type.String()),
}),
]);
export function createCanvasTool(): AnyAgentTool {
return {
label: "Canvas",
name: "canvas",
description:
"Control node canvases (present/hide/navigate/eval/snapshot/A2UI). Use snapshot to capture the rendered UI.",
parameters: CanvasToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const action = readStringParam(params, "action", { required: true });
const gatewayOpts: GatewayCallOptions = {
gatewayUrl: readStringParam(params, "gatewayUrl", { trim: false }),
gatewayToken: readStringParam(params, "gatewayToken", { trim: false }),
timeoutMs:
typeof params.timeoutMs === "number" ? params.timeoutMs : undefined,
};
const nodeId = await resolveNodeId(
gatewayOpts,
readStringParam(params, "node", { trim: true }),
true,
);
const invoke = async (
command: string,
invokeParams?: Record<string, unknown>,
) =>
await callGatewayTool("node.invoke", gatewayOpts, {
nodeId,
command,
params: invokeParams,
idempotencyKey: crypto.randomUUID(),
});
switch (action) {
case "present": {
const placement = {
x: typeof params.x === "number" ? params.x : undefined,
y: typeof params.y === "number" ? params.y : undefined,
width: typeof params.width === "number" ? params.width : undefined,
height:
typeof params.height === "number" ? params.height : undefined,
};
const invokeParams: Record<string, unknown> = {};
if (typeof params.target === "string" && params.target.trim()) {
invokeParams.url = params.target.trim();
}
if (
Number.isFinite(placement.x) ||
Number.isFinite(placement.y) ||
Number.isFinite(placement.width) ||
Number.isFinite(placement.height)
) {
invokeParams.placement = placement;
}
await invoke("canvas.present", invokeParams);
return jsonResult({ ok: true });
}
case "hide":
await invoke("canvas.hide", undefined);
return jsonResult({ ok: true });
case "navigate": {
const url = readStringParam(params, "url", { required: true });
await invoke("canvas.navigate", { url });
return jsonResult({ ok: true });
}
case "eval": {
const javaScript = readStringParam(params, "javaScript", {
required: true,
});
const raw = (await invoke("canvas.eval", { javaScript })) as {
payload?: { result?: string };
};
const result = raw?.payload?.result;
if (result) {
return {
content: [{ type: "text", text: result }],
details: { result },
};
}
return jsonResult({ ok: true });
}
case "snapshot": {
const formatRaw =
typeof params.format === "string"
? params.format.toLowerCase()
: "png";
const format =
formatRaw === "jpg" || formatRaw === "jpeg" ? "jpeg" : "png";
const maxWidth =
typeof params.maxWidth === "number" &&
Number.isFinite(params.maxWidth)
? params.maxWidth
: undefined;
const quality =
typeof params.quality === "number" &&
Number.isFinite(params.quality)
? params.quality
: undefined;
const raw = (await invoke("canvas.snapshot", {
format,
maxWidth,
quality,
})) as { payload?: unknown };
const payload = parseCanvasSnapshotPayload(raw?.payload);
const filePath = canvasSnapshotTempPath({
ext: payload.format === "jpeg" ? "jpg" : payload.format,
});
await writeBase64ToFile(filePath, payload.base64);
const mimeType = imageMimeFromFormat(payload.format) ?? "image/png";
return await imageResult({
label: "canvas:snapshot",
path: filePath,
base64: payload.base64,
mimeType,
details: { format: payload.format },
});
}
case "a2ui_push": {
const jsonl =
typeof params.jsonl === "string" && params.jsonl.trim()
? params.jsonl
: typeof params.jsonlPath === "string" && params.jsonlPath.trim()
? await fs.readFile(params.jsonlPath.trim(), "utf8")
: "";
if (!jsonl.trim()) throw new Error("jsonl or jsonlPath required");
await invoke("canvas.a2ui.pushJSONL", { jsonl });
return jsonResult({ ok: true });
}
case "a2ui_reset":
await invoke("canvas.a2ui.reset", undefined);
return jsonResult({ ok: true });
default:
throw new Error(`Unknown action: ${action}`);
}
},
};
}
+141
View File
@@ -0,0 +1,141 @@
import fs from "node:fs/promises";
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
import { detectMime } from "../../media/mime.js";
import { sanitizeToolResultImages } from "../tool-images.js";
// biome-ignore lint/suspicious/noExplicitAny: TypeBox schema type from pi-agent-core uses a different module instance.
export type AnyAgentTool = AgentTool<any, unknown>;
export type StringParamOptions = {
required?: boolean;
trim?: boolean;
label?: string;
};
export function readStringParam(
params: Record<string, unknown>,
key: string,
options: StringParamOptions & { required: true },
): string;
export function readStringParam(
params: Record<string, unknown>,
key: string,
options?: StringParamOptions,
): string | undefined;
export function readStringParam(
params: Record<string, unknown>,
key: string,
options: StringParamOptions = {},
) {
const { required = false, trim = true, label = key } = options;
const raw = params[key];
if (typeof raw !== "string") {
if (required) throw new Error(`${label} required`);
return undefined;
}
const value = trim ? raw.trim() : raw;
if (!value) {
if (required) throw new Error(`${label} required`);
return undefined;
}
return value;
}
export function readStringArrayParam(
params: Record<string, unknown>,
key: string,
options: StringParamOptions & { required: true },
): string[];
export function readStringArrayParam(
params: Record<string, unknown>,
key: string,
options?: StringParamOptions,
): string[] | undefined;
export function readStringArrayParam(
params: Record<string, unknown>,
key: string,
options: StringParamOptions = {},
) {
const { required = false, label = key } = options;
const raw = params[key];
if (Array.isArray(raw)) {
const values = raw
.filter((entry) => typeof entry === "string")
.map((entry) => entry.trim())
.filter(Boolean);
if (values.length === 0) {
if (required) throw new Error(`${label} required`);
return undefined;
}
return values;
}
if (typeof raw === "string") {
const value = raw.trim();
if (!value) {
if (required) throw new Error(`${label} required`);
return undefined;
}
return [value];
}
if (required) throw new Error(`${label} required`);
return undefined;
}
export function jsonResult(payload: unknown): AgentToolResult<unknown> {
return {
content: [
{
type: "text",
text: JSON.stringify(payload, null, 2),
},
],
details: payload,
};
}
export async function imageResult(params: {
label: string;
path: string;
base64: string;
mimeType: string;
extraText?: string;
details?: Record<string, unknown>;
}): Promise<AgentToolResult<unknown>> {
const content: AgentToolResult<unknown>["content"] = [
{
type: "text",
text: params.extraText ?? `MEDIA:${params.path}`,
},
{
type: "image",
data: params.base64,
mimeType: params.mimeType,
},
];
const result: AgentToolResult<unknown> = {
content,
details: { path: params.path, ...params.details },
};
return await sanitizeToolResultImages(result, params.label);
}
export async function imageResultFromFile(params: {
label: string;
path: string;
extraText?: string;
details?: Record<string, unknown>;
}): Promise<AgentToolResult<unknown>> {
const buf = await fs.readFile(params.path);
const mimeType =
(await detectMime({ buffer: buf.slice(0, 256) })) ?? "image/png";
return await imageResult({
label: params.label,
path: params.path,
base64: buf.toString("base64"),
mimeType,
extraText: params.extraText,
details: params.details,
});
}
+154
View File
@@ -0,0 +1,154 @@
import { Type } from "@sinclair/typebox";
import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
import { callGatewayTool, type GatewayCallOptions } from "./gateway.js";
const CronToolSchema = Type.Union([
Type.Object({
action: Type.Literal("status"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
}),
Type.Object({
action: Type.Literal("list"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
includeDisabled: Type.Optional(Type.Boolean()),
}),
Type.Object({
action: Type.Literal("add"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
job: Type.Object({}, { additionalProperties: true }),
}),
Type.Object({
action: Type.Literal("update"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
jobId: Type.String(),
patch: Type.Object({}, { additionalProperties: true }),
}),
Type.Object({
action: Type.Literal("remove"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
jobId: Type.String(),
}),
Type.Object({
action: Type.Literal("run"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
jobId: Type.String(),
}),
Type.Object({
action: Type.Literal("runs"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
jobId: Type.String(),
}),
Type.Object({
action: Type.Literal("wake"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
text: Type.String(),
mode: Type.Optional(
Type.Union([Type.Literal("now"), Type.Literal("next-heartbeat")]),
),
}),
]);
export function createCronTool(): AnyAgentTool {
return {
label: "Cron",
name: "cron",
description:
"Manage Gateway cron jobs (status/list/add/update/remove/run/runs) and send wake events.",
parameters: CronToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const action = readStringParam(params, "action", { required: true });
const gatewayOpts: GatewayCallOptions = {
gatewayUrl: readStringParam(params, "gatewayUrl", { trim: false }),
gatewayToken: readStringParam(params, "gatewayToken", { trim: false }),
timeoutMs:
typeof params.timeoutMs === "number" ? params.timeoutMs : undefined,
};
switch (action) {
case "status":
return jsonResult(
await callGatewayTool("cron.status", gatewayOpts, {}),
);
case "list":
return jsonResult(
await callGatewayTool("cron.list", gatewayOpts, {
includeDisabled: Boolean(params.includeDisabled),
}),
);
case "add": {
if (!params.job || typeof params.job !== "object") {
throw new Error("job required");
}
return jsonResult(
await callGatewayTool("cron.add", gatewayOpts, params.job),
);
}
case "update": {
const jobId = readStringParam(params, "jobId", { required: true });
if (!params.patch || typeof params.patch !== "object") {
throw new Error("patch required");
}
return jsonResult(
await callGatewayTool("cron.update", gatewayOpts, {
jobId,
patch: params.patch,
}),
);
}
case "remove": {
const jobId = readStringParam(params, "jobId", { required: true });
return jsonResult(
await callGatewayTool("cron.remove", gatewayOpts, { jobId }),
);
}
case "run": {
const jobId = readStringParam(params, "jobId", { required: true });
return jsonResult(
await callGatewayTool("cron.run", gatewayOpts, { jobId }),
);
}
case "runs": {
const jobId = readStringParam(params, "jobId", { required: true });
return jsonResult(
await callGatewayTool("cron.runs", gatewayOpts, { jobId }),
);
}
case "wake": {
const text = readStringParam(params, "text", { required: true });
const mode =
params.mode === "now" || params.mode === "next-heartbeat"
? params.mode
: "next-heartbeat";
return jsonResult(
await callGatewayTool(
"wake",
gatewayOpts,
{ mode, text },
{ expectFinal: false },
),
);
}
default:
throw new Error(`Unknown action: ${action}`);
}
},
};
}
+213
View File
@@ -0,0 +1,213 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { DiscordActionConfig } from "../../config/config.js";
import {
addRoleDiscord,
createScheduledEventDiscord,
fetchChannelInfoDiscord,
fetchMemberInfoDiscord,
fetchRoleInfoDiscord,
fetchVoiceStatusDiscord,
listGuildChannelsDiscord,
listGuildEmojisDiscord,
listScheduledEventsDiscord,
removeRoleDiscord,
uploadEmojiDiscord,
uploadStickerDiscord,
} from "../../discord/send.js";
import { jsonResult, readStringArrayParam, readStringParam } from "./common.js";
type ActionGate = (
key: keyof DiscordActionConfig,
defaultValue?: boolean,
) => boolean;
export async function handleDiscordGuildAction(
action: string,
params: Record<string, unknown>,
isActionEnabled: ActionGate,
): Promise<AgentToolResult<unknown>> {
switch (action) {
case "memberInfo": {
if (!isActionEnabled("memberInfo")) {
throw new Error("Discord member info is disabled.");
}
const guildId = readStringParam(params, "guildId", {
required: true,
});
const userId = readStringParam(params, "userId", {
required: true,
});
const member = await fetchMemberInfoDiscord(guildId, userId);
return jsonResult({ ok: true, member });
}
case "roleInfo": {
if (!isActionEnabled("roleInfo")) {
throw new Error("Discord role info is disabled.");
}
const guildId = readStringParam(params, "guildId", {
required: true,
});
const roles = await fetchRoleInfoDiscord(guildId);
return jsonResult({ ok: true, roles });
}
case "emojiList": {
if (!isActionEnabled("reactions")) {
throw new Error("Discord reactions are disabled.");
}
const guildId = readStringParam(params, "guildId", {
required: true,
});
const emojis = await listGuildEmojisDiscord(guildId);
return jsonResult({ ok: true, emojis });
}
case "emojiUpload": {
if (!isActionEnabled("emojiUploads")) {
throw new Error("Discord emoji uploads are disabled.");
}
const guildId = readStringParam(params, "guildId", {
required: true,
});
const name = readStringParam(params, "name", { required: true });
const mediaUrl = readStringParam(params, "mediaUrl", {
required: true,
});
const roleIds = readStringArrayParam(params, "roleIds");
const emoji = await uploadEmojiDiscord({
guildId,
name,
mediaUrl,
roleIds: roleIds?.length ? roleIds : undefined,
});
return jsonResult({ ok: true, emoji });
}
case "stickerUpload": {
if (!isActionEnabled("stickerUploads")) {
throw new Error("Discord sticker uploads are disabled.");
}
const guildId = readStringParam(params, "guildId", {
required: true,
});
const name = readStringParam(params, "name", { required: true });
const description = readStringParam(params, "description", {
required: true,
});
const tags = readStringParam(params, "tags", { required: true });
const mediaUrl = readStringParam(params, "mediaUrl", {
required: true,
});
const sticker = await uploadStickerDiscord({
guildId,
name,
description,
tags,
mediaUrl,
});
return jsonResult({ ok: true, sticker });
}
case "roleAdd": {
if (!isActionEnabled("roles", false)) {
throw new Error("Discord role changes are disabled.");
}
const guildId = readStringParam(params, "guildId", {
required: true,
});
const userId = readStringParam(params, "userId", {
required: true,
});
const roleId = readStringParam(params, "roleId", { required: true });
await addRoleDiscord({ guildId, userId, roleId });
return jsonResult({ ok: true });
}
case "roleRemove": {
if (!isActionEnabled("roles", false)) {
throw new Error("Discord role changes are disabled.");
}
const guildId = readStringParam(params, "guildId", {
required: true,
});
const userId = readStringParam(params, "userId", {
required: true,
});
const roleId = readStringParam(params, "roleId", { required: true });
await removeRoleDiscord({ guildId, userId, roleId });
return jsonResult({ ok: true });
}
case "channelInfo": {
if (!isActionEnabled("channelInfo")) {
throw new Error("Discord channel info is disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const channel = await fetchChannelInfoDiscord(channelId);
return jsonResult({ ok: true, channel });
}
case "channelList": {
if (!isActionEnabled("channelInfo")) {
throw new Error("Discord channel info is disabled.");
}
const guildId = readStringParam(params, "guildId", {
required: true,
});
const channels = await listGuildChannelsDiscord(guildId);
return jsonResult({ ok: true, channels });
}
case "voiceStatus": {
if (!isActionEnabled("voiceStatus")) {
throw new Error("Discord voice status is disabled.");
}
const guildId = readStringParam(params, "guildId", {
required: true,
});
const userId = readStringParam(params, "userId", {
required: true,
});
const voice = await fetchVoiceStatusDiscord(guildId, userId);
return jsonResult({ ok: true, voice });
}
case "eventList": {
if (!isActionEnabled("events")) {
throw new Error("Discord events are disabled.");
}
const guildId = readStringParam(params, "guildId", {
required: true,
});
const events = await listScheduledEventsDiscord(guildId);
return jsonResult({ ok: true, events });
}
case "eventCreate": {
if (!isActionEnabled("events")) {
throw new Error("Discord events are disabled.");
}
const guildId = readStringParam(params, "guildId", {
required: true,
});
const name = readStringParam(params, "name", { required: true });
const startTime = readStringParam(params, "startTime", {
required: true,
});
const endTime = readStringParam(params, "endTime");
const description = readStringParam(params, "description");
const channelId = readStringParam(params, "channelId");
const location = readStringParam(params, "location");
const entityTypeRaw = readStringParam(params, "entityType");
const entityType =
entityTypeRaw === "stage" ? 1 : entityTypeRaw === "external" ? 3 : 2;
const payload = {
name,
description,
scheduled_start_time: startTime,
scheduled_end_time: endTime,
entity_type: entityType,
channel_id: channelId,
entity_metadata:
entityType === 3 && location ? { location } : undefined,
privacy_level: 2,
};
const event = await createScheduledEventDiscord(guildId, payload);
return jsonResult({ ok: true, event });
}
default:
throw new Error(`Unknown action: ${action}`);
}
}
@@ -0,0 +1,325 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { DiscordActionConfig } from "../../config/config.js";
import {
createThreadDiscord,
deleteMessageDiscord,
editMessageDiscord,
fetchChannelPermissionsDiscord,
fetchReactionsDiscord,
listPinsDiscord,
listThreadsDiscord,
pinMessageDiscord,
reactMessageDiscord,
readMessagesDiscord,
searchMessagesDiscord,
sendMessageDiscord,
sendPollDiscord,
sendStickerDiscord,
unpinMessageDiscord,
} from "../../discord/send.js";
import { jsonResult, readStringArrayParam, readStringParam } from "./common.js";
type ActionGate = (
key: keyof DiscordActionConfig,
defaultValue?: boolean,
) => boolean;
export async function handleDiscordMessagingAction(
action: string,
params: Record<string, unknown>,
isActionEnabled: ActionGate,
): Promise<AgentToolResult<unknown>> {
switch (action) {
case "react": {
if (!isActionEnabled("reactions")) {
throw new Error("Discord reactions are disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const messageId = readStringParam(params, "messageId", {
required: true,
});
const emoji = readStringParam(params, "emoji", { required: true });
await reactMessageDiscord(channelId, messageId, emoji);
return jsonResult({ ok: true });
}
case "reactions": {
if (!isActionEnabled("reactions")) {
throw new Error("Discord reactions are disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const messageId = readStringParam(params, "messageId", {
required: true,
});
const limitRaw = params.limit;
const limit =
typeof limitRaw === "number" && Number.isFinite(limitRaw)
? limitRaw
: undefined;
const reactions = await fetchReactionsDiscord(channelId, messageId, {
limit,
});
return jsonResult({ ok: true, reactions });
}
case "sticker": {
if (!isActionEnabled("stickers")) {
throw new Error("Discord stickers are disabled.");
}
const to = readStringParam(params, "to", { required: true });
const content = readStringParam(params, "content");
const stickerIds = readStringArrayParam(params, "stickerIds", {
required: true,
label: "stickerIds",
});
await sendStickerDiscord(to, stickerIds, { content });
return jsonResult({ ok: true });
}
case "poll": {
if (!isActionEnabled("polls")) {
throw new Error("Discord polls are disabled.");
}
const to = readStringParam(params, "to", { required: true });
const content = readStringParam(params, "content");
const question = readStringParam(params, "question", {
required: true,
});
const answers = readStringArrayParam(params, "answers", {
required: true,
label: "answers",
});
const allowMultiselectRaw = params.allowMultiselect;
const allowMultiselect =
typeof allowMultiselectRaw === "boolean"
? allowMultiselectRaw
: undefined;
const durationRaw = params.durationHours;
const durationHours =
typeof durationRaw === "number" && Number.isFinite(durationRaw)
? durationRaw
: undefined;
await sendPollDiscord(
to,
{ question, answers, allowMultiselect, durationHours },
{ content },
);
return jsonResult({ ok: true });
}
case "permissions": {
if (!isActionEnabled("permissions")) {
throw new Error("Discord permissions are disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const permissions = await fetchChannelPermissionsDiscord(channelId);
return jsonResult({ ok: true, permissions });
}
case "readMessages": {
if (!isActionEnabled("messages")) {
throw new Error("Discord message reads are disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const messages = await readMessagesDiscord(channelId, {
limit:
typeof params.limit === "number" && Number.isFinite(params.limit)
? params.limit
: undefined,
before: readStringParam(params, "before"),
after: readStringParam(params, "after"),
around: readStringParam(params, "around"),
});
return jsonResult({ ok: true, messages });
}
case "sendMessage": {
if (!isActionEnabled("messages")) {
throw new Error("Discord message sends are disabled.");
}
const to = readStringParam(params, "to", { required: true });
const content = readStringParam(params, "content", {
required: true,
});
const mediaUrl = readStringParam(params, "mediaUrl");
const replyTo = readStringParam(params, "replyTo");
const result = await sendMessageDiscord(to, content, {
mediaUrl,
replyTo,
});
return jsonResult({ ok: true, result });
}
case "editMessage": {
if (!isActionEnabled("messages")) {
throw new Error("Discord message edits are disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const messageId = readStringParam(params, "messageId", {
required: true,
});
const content = readStringParam(params, "content", {
required: true,
});
const message = await editMessageDiscord(channelId, messageId, {
content,
});
return jsonResult({ ok: true, message });
}
case "deleteMessage": {
if (!isActionEnabled("messages")) {
throw new Error("Discord message deletes are disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const messageId = readStringParam(params, "messageId", {
required: true,
});
await deleteMessageDiscord(channelId, messageId);
return jsonResult({ ok: true });
}
case "threadCreate": {
if (!isActionEnabled("threads")) {
throw new Error("Discord threads are disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const name = readStringParam(params, "name", { required: true });
const messageId = readStringParam(params, "messageId");
const autoArchiveMinutesRaw = params.autoArchiveMinutes;
const autoArchiveMinutes =
typeof autoArchiveMinutesRaw === "number" &&
Number.isFinite(autoArchiveMinutesRaw)
? autoArchiveMinutesRaw
: undefined;
const thread = await createThreadDiscord(channelId, {
name,
messageId,
autoArchiveMinutes,
});
return jsonResult({ ok: true, thread });
}
case "threadList": {
if (!isActionEnabled("threads")) {
throw new Error("Discord threads are disabled.");
}
const guildId = readStringParam(params, "guildId", {
required: true,
});
const channelId = readStringParam(params, "channelId");
const includeArchived =
typeof params.includeArchived === "boolean"
? params.includeArchived
: undefined;
const before = readStringParam(params, "before");
const limit =
typeof params.limit === "number" && Number.isFinite(params.limit)
? params.limit
: undefined;
const threads = await listThreadsDiscord({
guildId,
channelId,
includeArchived,
before,
limit,
});
return jsonResult({ ok: true, threads });
}
case "threadReply": {
if (!isActionEnabled("threads")) {
throw new Error("Discord threads are disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const content = readStringParam(params, "content", {
required: true,
});
const mediaUrl = readStringParam(params, "mediaUrl");
const replyTo = readStringParam(params, "replyTo");
const result = await sendMessageDiscord(`channel:${channelId}`, content, {
mediaUrl,
replyTo,
});
return jsonResult({ ok: true, result });
}
case "pinMessage": {
if (!isActionEnabled("pins")) {
throw new Error("Discord pins are disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const messageId = readStringParam(params, "messageId", {
required: true,
});
await pinMessageDiscord(channelId, messageId);
return jsonResult({ ok: true });
}
case "unpinMessage": {
if (!isActionEnabled("pins")) {
throw new Error("Discord pins are disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const messageId = readStringParam(params, "messageId", {
required: true,
});
await unpinMessageDiscord(channelId, messageId);
return jsonResult({ ok: true });
}
case "listPins": {
if (!isActionEnabled("pins")) {
throw new Error("Discord pins are disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const pins = await listPinsDiscord(channelId);
return jsonResult({ ok: true, pins });
}
case "searchMessages": {
if (!isActionEnabled("search")) {
throw new Error("Discord search is disabled.");
}
const guildId = readStringParam(params, "guildId", {
required: true,
});
const content = readStringParam(params, "content", {
required: true,
});
const channelId = readStringParam(params, "channelId");
const channelIds = readStringArrayParam(params, "channelIds");
const authorId = readStringParam(params, "authorId");
const authorIds = readStringArrayParam(params, "authorIds");
const limit =
typeof params.limit === "number" && Number.isFinite(params.limit)
? params.limit
: undefined;
const channelIdList = [
...(channelIds ?? []),
...(channelId ? [channelId] : []),
];
const authorIdList = [
...(authorIds ?? []),
...(authorId ? [authorId] : []),
];
const results = await searchMessagesDiscord({
guildId,
content,
channelIds: channelIdList.length ? channelIdList : undefined,
authorIds: authorIdList.length ? authorIdList : undefined,
limit,
});
return jsonResult({ ok: true, results });
}
default:
throw new Error(`Unknown action: ${action}`);
}
}
@@ -0,0 +1,88 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { DiscordActionConfig } from "../../config/config.js";
import {
banMemberDiscord,
kickMemberDiscord,
timeoutMemberDiscord,
} from "../../discord/send.js";
import { jsonResult, readStringParam } from "./common.js";
type ActionGate = (
key: keyof DiscordActionConfig,
defaultValue?: boolean,
) => boolean;
export async function handleDiscordModerationAction(
action: string,
params: Record<string, unknown>,
isActionEnabled: ActionGate,
): Promise<AgentToolResult<unknown>> {
switch (action) {
case "timeout": {
if (!isActionEnabled("moderation", false)) {
throw new Error("Discord moderation is disabled.");
}
const guildId = readStringParam(params, "guildId", {
required: true,
});
const userId = readStringParam(params, "userId", {
required: true,
});
const durationMinutes =
typeof params.durationMinutes === "number" &&
Number.isFinite(params.durationMinutes)
? params.durationMinutes
: undefined;
const until = readStringParam(params, "until");
const reason = readStringParam(params, "reason");
const member = await timeoutMemberDiscord({
guildId,
userId,
durationMinutes,
until,
reason,
});
return jsonResult({ ok: true, member });
}
case "kick": {
if (!isActionEnabled("moderation", false)) {
throw new Error("Discord moderation is disabled.");
}
const guildId = readStringParam(params, "guildId", {
required: true,
});
const userId = readStringParam(params, "userId", {
required: true,
});
const reason = readStringParam(params, "reason");
await kickMemberDiscord({ guildId, userId, reason });
return jsonResult({ ok: true });
}
case "ban": {
if (!isActionEnabled("moderation", false)) {
throw new Error("Discord moderation is disabled.");
}
const guildId = readStringParam(params, "guildId", {
required: true,
});
const userId = readStringParam(params, "userId", {
required: true,
});
const reason = readStringParam(params, "reason");
const deleteMessageDays =
typeof params.deleteMessageDays === "number" &&
Number.isFinite(params.deleteMessageDays)
? params.deleteMessageDays
: undefined;
await banMemberDiscord({
guildId,
userId,
reason,
deleteMessageDays,
});
return jsonResult({ ok: true });
}
default:
throw new Error(`Unknown action: ${action}`);
}
}
+73
View File
@@ -0,0 +1,73 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type {
ClawdisConfig,
DiscordActionConfig,
} from "../../config/config.js";
import { readStringParam } from "./common.js";
import { handleDiscordGuildAction } from "./discord-actions-guild.js";
import { handleDiscordMessagingAction } from "./discord-actions-messaging.js";
import { handleDiscordModerationAction } from "./discord-actions-moderation.js";
const messagingActions = new Set([
"react",
"reactions",
"sticker",
"poll",
"permissions",
"readMessages",
"sendMessage",
"editMessage",
"deleteMessage",
"threadCreate",
"threadList",
"threadReply",
"pinMessage",
"unpinMessage",
"listPins",
"searchMessages",
]);
const guildActions = new Set([
"memberInfo",
"roleInfo",
"emojiList",
"emojiUpload",
"stickerUpload",
"roleAdd",
"roleRemove",
"channelInfo",
"channelList",
"voiceStatus",
"eventList",
"eventCreate",
]);
const moderationActions = new Set(["timeout", "kick", "ban"]);
type ActionGate = (
key: keyof DiscordActionConfig,
defaultValue?: boolean,
) => boolean;
export async function handleDiscordAction(
params: Record<string, unknown>,
cfg: ClawdisConfig,
): Promise<AgentToolResult<unknown>> {
const action = readStringParam(params, "action", { required: true });
const isActionEnabled: ActionGate = (key, defaultValue = true) => {
const value = cfg.discord?.actions?.[key];
if (value === undefined) return defaultValue;
return value !== false;
};
if (messagingActions.has(action)) {
return await handleDiscordMessagingAction(action, params, isActionEnabled);
}
if (guildActions.has(action)) {
return await handleDiscordGuildAction(action, params, isActionEnabled);
}
if (moderationActions.has(action)) {
return await handleDiscordModerationAction(action, params, isActionEnabled);
}
throw new Error(`Unknown action: ${action}`);
}
+202
View File
@@ -0,0 +1,202 @@
import { Type } from "@sinclair/typebox";
export const DiscordToolSchema = Type.Union([
Type.Object({
action: Type.Literal("react"),
channelId: Type.String(),
messageId: Type.String(),
emoji: Type.String(),
}),
Type.Object({
action: Type.Literal("reactions"),
channelId: Type.String(),
messageId: Type.String(),
limit: Type.Optional(Type.Number()),
}),
Type.Object({
action: Type.Literal("sticker"),
to: Type.String(),
stickerIds: Type.Array(Type.String()),
content: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("poll"),
to: Type.String(),
question: Type.String(),
answers: Type.Array(Type.String()),
allowMultiselect: Type.Optional(Type.Boolean()),
durationHours: Type.Optional(Type.Number()),
content: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("permissions"),
channelId: Type.String(),
}),
Type.Object({
action: Type.Literal("readMessages"),
channelId: Type.String(),
limit: Type.Optional(Type.Number()),
before: Type.Optional(Type.String()),
after: Type.Optional(Type.String()),
around: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("sendMessage"),
to: Type.String(),
content: Type.String(),
mediaUrl: Type.Optional(Type.String()),
replyTo: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("editMessage"),
channelId: Type.String(),
messageId: Type.String(),
content: Type.String(),
}),
Type.Object({
action: Type.Literal("deleteMessage"),
channelId: Type.String(),
messageId: Type.String(),
}),
Type.Object({
action: Type.Literal("threadCreate"),
channelId: Type.String(),
name: Type.String(),
messageId: Type.Optional(Type.String()),
autoArchiveMinutes: Type.Optional(Type.Number()),
}),
Type.Object({
action: Type.Literal("threadList"),
guildId: Type.String(),
channelId: Type.Optional(Type.String()),
includeArchived: Type.Optional(Type.Boolean()),
before: Type.Optional(Type.String()),
limit: Type.Optional(Type.Number()),
}),
Type.Object({
action: Type.Literal("threadReply"),
channelId: Type.String(),
content: Type.String(),
mediaUrl: Type.Optional(Type.String()),
replyTo: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("pinMessage"),
channelId: Type.String(),
messageId: Type.String(),
}),
Type.Object({
action: Type.Literal("unpinMessage"),
channelId: Type.String(),
messageId: Type.String(),
}),
Type.Object({
action: Type.Literal("listPins"),
channelId: Type.String(),
}),
Type.Object({
action: Type.Literal("searchMessages"),
guildId: Type.String(),
content: Type.String(),
channelId: Type.Optional(Type.String()),
channelIds: Type.Optional(Type.Array(Type.String())),
authorId: Type.Optional(Type.String()),
authorIds: Type.Optional(Type.Array(Type.String())),
limit: Type.Optional(Type.Number()),
}),
Type.Object({
action: Type.Literal("memberInfo"),
guildId: Type.String(),
userId: Type.String(),
}),
Type.Object({
action: Type.Literal("roleInfo"),
guildId: Type.String(),
}),
Type.Object({
action: Type.Literal("emojiList"),
guildId: Type.String(),
}),
Type.Object({
action: Type.Literal("emojiUpload"),
guildId: Type.String(),
name: Type.String(),
mediaUrl: Type.String(),
roleIds: Type.Optional(Type.Array(Type.String())),
}),
Type.Object({
action: Type.Literal("stickerUpload"),
guildId: Type.String(),
name: Type.String(),
description: Type.String(),
tags: Type.String(),
mediaUrl: Type.String(),
}),
Type.Object({
action: Type.Literal("roleAdd"),
guildId: Type.String(),
userId: Type.String(),
roleId: Type.String(),
}),
Type.Object({
action: Type.Literal("roleRemove"),
guildId: Type.String(),
userId: Type.String(),
roleId: Type.String(),
}),
Type.Object({
action: Type.Literal("channelInfo"),
channelId: Type.String(),
}),
Type.Object({
action: Type.Literal("channelList"),
guildId: Type.String(),
}),
Type.Object({
action: Type.Literal("voiceStatus"),
guildId: Type.String(),
userId: Type.String(),
}),
Type.Object({
action: Type.Literal("eventList"),
guildId: Type.String(),
}),
Type.Object({
action: Type.Literal("eventCreate"),
guildId: Type.String(),
name: Type.String(),
startTime: Type.String(),
endTime: Type.Optional(Type.String()),
description: Type.Optional(Type.String()),
channelId: Type.Optional(Type.String()),
entityType: Type.Optional(
Type.Union([
Type.Literal("voice"),
Type.Literal("stage"),
Type.Literal("external"),
]),
),
location: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("timeout"),
guildId: Type.String(),
userId: Type.String(),
durationMinutes: Type.Optional(Type.Number()),
until: Type.Optional(Type.String()),
reason: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("kick"),
guildId: Type.String(),
userId: Type.String(),
reason: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("ban"),
guildId: Type.String(),
userId: Type.String(),
reason: Type.Optional(Type.String()),
deleteMessageDays: Type.Optional(Type.Number()),
}),
]);
+18
View File
@@ -0,0 +1,18 @@
import { loadConfig } from "../../config/config.js";
import type { AnyAgentTool } from "./common.js";
import { handleDiscordAction } from "./discord-actions.js";
import { DiscordToolSchema } from "./discord-schema.js";
export function createDiscordTool(): AnyAgentTool {
return {
label: "Discord",
name: "discord",
description: "Manage Discord messages, reactions, and moderation.",
parameters: DiscordToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const cfg = loadConfig();
return await handleDiscordAction(params, cfg);
},
};
}
+53
View File
@@ -0,0 +1,53 @@
import { Type } from "@sinclair/typebox";
import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
const GatewayToolSchema = Type.Union([
Type.Object({
action: Type.Literal("restart"),
delayMs: Type.Optional(Type.Number()),
reason: Type.Optional(Type.String()),
}),
]);
export function createGatewayTool(): AnyAgentTool {
return {
label: "Gateway",
name: "gateway",
description:
"Restart the running gateway process in-place (SIGUSR1) without needing an external supervisor. Use delayMs to avoid interrupting an in-flight reply.",
parameters: GatewayToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const action = readStringParam(params, "action", { required: true });
if (action !== "restart") throw new Error(`Unknown action: ${action}`);
const delayMsRaw =
typeof params.delayMs === "number" && Number.isFinite(params.delayMs)
? Math.floor(params.delayMs)
: 2000;
const delayMs = Math.min(Math.max(delayMsRaw, 0), 60_000);
const reason =
typeof params.reason === "string" && params.reason.trim()
? params.reason.trim().slice(0, 200)
: undefined;
const pid = process.pid;
setTimeout(() => {
try {
process.kill(pid, "SIGUSR1");
} catch {
/* ignore */
}
}, delayMs);
return jsonResult({
ok: true,
pid,
signal: "SIGUSR1",
delayMs,
reason: reason ?? null,
});
},
};
}
+44
View File
@@ -0,0 +1,44 @@
import { callGateway } from "../../gateway/call.js";
export const DEFAULT_GATEWAY_URL = "ws://127.0.0.1:18789";
export type GatewayCallOptions = {
gatewayUrl?: string;
gatewayToken?: string;
timeoutMs?: number;
};
export function resolveGatewayOptions(opts?: GatewayCallOptions) {
const url =
typeof opts?.gatewayUrl === "string" && opts.gatewayUrl.trim()
? opts.gatewayUrl.trim()
: DEFAULT_GATEWAY_URL;
const token =
typeof opts?.gatewayToken === "string" && opts.gatewayToken.trim()
? opts.gatewayToken.trim()
: undefined;
const timeoutMs =
typeof opts?.timeoutMs === "number" && Number.isFinite(opts.timeoutMs)
? Math.max(1, Math.floor(opts.timeoutMs))
: 10_000;
return { url, token, timeoutMs };
}
export async function callGatewayTool<T = unknown>(
method: string,
opts: GatewayCallOptions,
params?: unknown,
extra?: { expectFinal?: boolean },
) {
const gateway = resolveGatewayOptions(opts);
return await callGateway<T>({
url: gateway.url,
token: gateway.token,
method,
params,
timeoutMs: gateway.timeoutMs,
expectFinal: extra?.expectFinal,
clientName: "agent",
mode: "agent",
});
}
+486
View File
@@ -0,0 +1,486 @@
import crypto from "node:crypto";
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox";
import {
type CameraFacing,
cameraTempPath,
parseCameraClipPayload,
parseCameraSnapPayload,
writeBase64ToFile,
} from "../../cli/nodes-camera.js";
import {
parseScreenRecordPayload,
screenRecordTempPath,
writeScreenRecordToFile,
} from "../../cli/nodes-screen.js";
import { parseDurationMs } from "../../cli/parse-duration.js";
import { imageMimeFromFormat } from "../../media/mime.js";
import { sanitizeToolResultImages } from "../tool-images.js";
import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
import { callGatewayTool, type GatewayCallOptions } from "./gateway.js";
import { resolveNodeId } from "./nodes-utils.js";
const NodesToolSchema = Type.Union([
Type.Object({
action: Type.Literal("status"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
}),
Type.Object({
action: Type.Literal("describe"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.String(),
}),
Type.Object({
action: Type.Literal("pending"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
}),
Type.Object({
action: Type.Literal("approve"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
requestId: Type.String(),
}),
Type.Object({
action: Type.Literal("reject"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
requestId: Type.String(),
}),
Type.Object({
action: Type.Literal("notify"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.String(),
title: Type.Optional(Type.String()),
body: Type.Optional(Type.String()),
sound: Type.Optional(Type.String()),
priority: Type.Optional(
Type.Union([
Type.Literal("passive"),
Type.Literal("active"),
Type.Literal("timeSensitive"),
]),
),
delivery: Type.Optional(
Type.Union([
Type.Literal("system"),
Type.Literal("overlay"),
Type.Literal("auto"),
]),
),
}),
Type.Object({
action: Type.Literal("camera_snap"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.String(),
facing: Type.Optional(
Type.Union([
Type.Literal("front"),
Type.Literal("back"),
Type.Literal("both"),
]),
),
maxWidth: Type.Optional(Type.Number()),
quality: Type.Optional(Type.Number()),
delayMs: Type.Optional(Type.Number()),
deviceId: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("camera_list"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.String(),
}),
Type.Object({
action: Type.Literal("camera_clip"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.String(),
facing: Type.Optional(
Type.Union([Type.Literal("front"), Type.Literal("back")]),
),
duration: Type.Optional(Type.String()),
durationMs: Type.Optional(Type.Number()),
includeAudio: Type.Optional(Type.Boolean()),
deviceId: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("screen_record"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.String(),
duration: Type.Optional(Type.String()),
durationMs: Type.Optional(Type.Number()),
fps: Type.Optional(Type.Number()),
screenIndex: Type.Optional(Type.Number()),
includeAudio: Type.Optional(Type.Boolean()),
outPath: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("location_get"),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.String(),
maxAgeMs: Type.Optional(Type.Number()),
locationTimeoutMs: Type.Optional(Type.Number()),
desiredAccuracy: Type.Optional(
Type.Union([
Type.Literal("coarse"),
Type.Literal("balanced"),
Type.Literal("precise"),
]),
),
}),
]);
export function createNodesTool(): AnyAgentTool {
return {
label: "Nodes",
name: "nodes",
description:
"Discover and control paired nodes (status/describe/pairing/notify/camera/screen/location).",
parameters: NodesToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const action = readStringParam(params, "action", { required: true });
const gatewayOpts: GatewayCallOptions = {
gatewayUrl: readStringParam(params, "gatewayUrl", { trim: false }),
gatewayToken: readStringParam(params, "gatewayToken", { trim: false }),
timeoutMs:
typeof params.timeoutMs === "number" ? params.timeoutMs : undefined,
};
switch (action) {
case "status":
return jsonResult(
await callGatewayTool("node.list", gatewayOpts, {}),
);
case "describe": {
const node = readStringParam(params, "node", { required: true });
const nodeId = await resolveNodeId(gatewayOpts, node);
return jsonResult(
await callGatewayTool("node.describe", gatewayOpts, { nodeId }),
);
}
case "pending":
return jsonResult(
await callGatewayTool("node.pair.list", gatewayOpts, {}),
);
case "approve": {
const requestId = readStringParam(params, "requestId", {
required: true,
});
return jsonResult(
await callGatewayTool("node.pair.approve", gatewayOpts, {
requestId,
}),
);
}
case "reject": {
const requestId = readStringParam(params, "requestId", {
required: true,
});
return jsonResult(
await callGatewayTool("node.pair.reject", gatewayOpts, {
requestId,
}),
);
}
case "notify": {
const node = readStringParam(params, "node", { required: true });
const title = typeof params.title === "string" ? params.title : "";
const body = typeof params.body === "string" ? params.body : "";
if (!title.trim() && !body.trim()) {
throw new Error("title or body required");
}
const nodeId = await resolveNodeId(gatewayOpts, node);
await callGatewayTool("node.invoke", gatewayOpts, {
nodeId,
command: "system.notify",
params: {
title: title.trim() || undefined,
body: body.trim() || undefined,
sound:
typeof params.sound === "string" ? params.sound : undefined,
priority:
typeof params.priority === "string"
? params.priority
: undefined,
delivery:
typeof params.delivery === "string"
? params.delivery
: undefined,
},
idempotencyKey: crypto.randomUUID(),
});
return jsonResult({ ok: true });
}
case "camera_snap": {
const node = readStringParam(params, "node", { required: true });
const nodeId = await resolveNodeId(gatewayOpts, node);
const facingRaw =
typeof params.facing === "string"
? params.facing.toLowerCase()
: "both";
const facings: CameraFacing[] =
facingRaw === "both"
? ["front", "back"]
: facingRaw === "front" || facingRaw === "back"
? [facingRaw]
: (() => {
throw new Error("invalid facing (front|back|both)");
})();
const maxWidth =
typeof params.maxWidth === "number" &&
Number.isFinite(params.maxWidth)
? params.maxWidth
: undefined;
const quality =
typeof params.quality === "number" &&
Number.isFinite(params.quality)
? params.quality
: undefined;
const delayMs =
typeof params.delayMs === "number" &&
Number.isFinite(params.delayMs)
? params.delayMs
: undefined;
const deviceId =
typeof params.deviceId === "string" && params.deviceId.trim()
? params.deviceId.trim()
: undefined;
const content: AgentToolResult<unknown>["content"] = [];
const details: Array<Record<string, unknown>> = [];
for (const facing of facings) {
const raw = (await callGatewayTool("node.invoke", gatewayOpts, {
nodeId,
command: "camera.snap",
params: {
facing,
maxWidth,
quality,
format: "jpg",
delayMs,
deviceId,
},
idempotencyKey: crypto.randomUUID(),
})) as { payload?: unknown };
const payload = parseCameraSnapPayload(raw?.payload);
const normalizedFormat = payload.format.toLowerCase();
if (
normalizedFormat !== "jpg" &&
normalizedFormat !== "jpeg" &&
normalizedFormat !== "png"
) {
throw new Error(
`unsupported camera.snap format: ${payload.format}`,
);
}
const isJpeg =
normalizedFormat === "jpg" || normalizedFormat === "jpeg";
const filePath = cameraTempPath({
kind: "snap",
facing,
ext: isJpeg ? "jpg" : "png",
});
await writeBase64ToFile(filePath, payload.base64);
content.push({ type: "text", text: `MEDIA:${filePath}` });
content.push({
type: "image",
data: payload.base64,
mimeType:
imageMimeFromFormat(payload.format) ??
(isJpeg ? "image/jpeg" : "image/png"),
});
details.push({
facing,
path: filePath,
width: payload.width,
height: payload.height,
});
}
const result: AgentToolResult<unknown> = { content, details };
return await sanitizeToolResultImages(result, "nodes:camera_snap");
}
case "camera_list": {
const node = readStringParam(params, "node", { required: true });
const nodeId = await resolveNodeId(gatewayOpts, node);
const raw = (await callGatewayTool("node.invoke", gatewayOpts, {
nodeId,
command: "camera.list",
params: {},
idempotencyKey: crypto.randomUUID(),
})) as { payload?: unknown };
const payload =
raw && typeof raw.payload === "object" && raw.payload !== null
? raw.payload
: {};
return jsonResult(payload);
}
case "camera_clip": {
const node = readStringParam(params, "node", { required: true });
const nodeId = await resolveNodeId(gatewayOpts, node);
const facing =
typeof params.facing === "string"
? params.facing.toLowerCase()
: "front";
if (facing !== "front" && facing !== "back") {
throw new Error("invalid facing (front|back)");
}
const durationMs =
typeof params.durationMs === "number" &&
Number.isFinite(params.durationMs)
? params.durationMs
: typeof params.duration === "string"
? parseDurationMs(params.duration)
: 3000;
const includeAudio =
typeof params.includeAudio === "boolean"
? params.includeAudio
: true;
const deviceId =
typeof params.deviceId === "string" && params.deviceId.trim()
? params.deviceId.trim()
: undefined;
const raw = (await callGatewayTool("node.invoke", gatewayOpts, {
nodeId,
command: "camera.clip",
params: {
facing,
durationMs,
includeAudio,
format: "mp4",
deviceId,
},
idempotencyKey: crypto.randomUUID(),
})) as { payload?: unknown };
const payload = parseCameraClipPayload(raw?.payload);
const filePath = cameraTempPath({
kind: "clip",
facing,
ext: payload.format,
});
await writeBase64ToFile(filePath, payload.base64);
return {
content: [{ type: "text", text: `FILE:${filePath}` }],
details: {
facing,
path: filePath,
durationMs: payload.durationMs,
hasAudio: payload.hasAudio,
},
};
}
case "screen_record": {
const node = readStringParam(params, "node", { required: true });
const nodeId = await resolveNodeId(gatewayOpts, node);
const durationMs =
typeof params.durationMs === "number" &&
Number.isFinite(params.durationMs)
? params.durationMs
: typeof params.duration === "string"
? parseDurationMs(params.duration)
: 10_000;
const fps =
typeof params.fps === "number" && Number.isFinite(params.fps)
? params.fps
: 10;
const screenIndex =
typeof params.screenIndex === "number" &&
Number.isFinite(params.screenIndex)
? params.screenIndex
: 0;
const includeAudio =
typeof params.includeAudio === "boolean"
? params.includeAudio
: true;
const raw = (await callGatewayTool("node.invoke", gatewayOpts, {
nodeId,
command: "screen.record",
params: {
durationMs,
screenIndex,
fps,
format: "mp4",
includeAudio,
},
idempotencyKey: crypto.randomUUID(),
})) as { payload?: unknown };
const payload = parseScreenRecordPayload(raw?.payload);
const filePath =
typeof params.outPath === "string" && params.outPath.trim()
? params.outPath.trim()
: screenRecordTempPath({ ext: payload.format || "mp4" });
const written = await writeScreenRecordToFile(
filePath,
payload.base64,
);
return {
content: [{ type: "text", text: `FILE:${written.path}` }],
details: {
path: written.path,
durationMs: payload.durationMs,
fps: payload.fps,
screenIndex: payload.screenIndex,
hasAudio: payload.hasAudio,
},
};
}
case "location_get": {
const node = readStringParam(params, "node", { required: true });
const nodeId = await resolveNodeId(gatewayOpts, node);
const maxAgeMs =
typeof params.maxAgeMs === "number" &&
Number.isFinite(params.maxAgeMs)
? params.maxAgeMs
: undefined;
const desiredAccuracy =
params.desiredAccuracy === "coarse" ||
params.desiredAccuracy === "balanced" ||
params.desiredAccuracy === "precise"
? params.desiredAccuracy
: undefined;
const locationTimeoutMs =
typeof params.locationTimeoutMs === "number" &&
Number.isFinite(params.locationTimeoutMs)
? params.locationTimeoutMs
: undefined;
const raw = (await callGatewayTool("node.invoke", gatewayOpts, {
nodeId,
command: "location.get",
params: {
maxAgeMs,
desiredAccuracy,
timeoutMs: locationTimeoutMs,
},
idempotencyKey: crypto.randomUUID(),
})) as { payload?: unknown };
return jsonResult(raw?.payload ?? {});
}
default:
throw new Error(`Unknown action: ${action}`);
}
},
};
}
+148
View File
@@ -0,0 +1,148 @@
import { callGatewayTool, type GatewayCallOptions } from "./gateway.js";
type NodeListNode = {
nodeId: string;
displayName?: string;
platform?: string;
remoteIp?: string;
deviceFamily?: string;
modelIdentifier?: string;
caps?: string[];
commands?: string[];
permissions?: Record<string, boolean>;
paired?: boolean;
connected?: boolean;
};
type PendingRequest = {
requestId: string;
nodeId: string;
displayName?: string;
platform?: string;
version?: string;
remoteIp?: string;
isRepair?: boolean;
ts: number;
};
type PairedNode = {
nodeId: string;
token?: string;
displayName?: string;
platform?: string;
version?: string;
remoteIp?: string;
permissions?: Record<string, boolean>;
createdAtMs?: number;
approvedAtMs?: number;
};
type PairingList = {
pending: PendingRequest[];
paired: PairedNode[];
};
function parseNodeList(value: unknown): NodeListNode[] {
const obj =
typeof value === "object" && value !== null
? (value as Record<string, unknown>)
: {};
return Array.isArray(obj.nodes) ? (obj.nodes as NodeListNode[]) : [];
}
function parsePairingList(value: unknown): PairingList {
const obj =
typeof value === "object" && value !== null
? (value as Record<string, unknown>)
: {};
const pending = Array.isArray(obj.pending)
? (obj.pending as PendingRequest[])
: [];
const paired = Array.isArray(obj.paired) ? (obj.paired as PairedNode[]) : [];
return { pending, paired };
}
function normalizeNodeKey(value: string) {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+/, "")
.replace(/-+$/, "");
}
async function loadNodes(opts: GatewayCallOptions): Promise<NodeListNode[]> {
try {
const res = (await callGatewayTool("node.list", opts, {})) as unknown;
return parseNodeList(res);
} catch {
const res = (await callGatewayTool("node.pair.list", opts, {})) as unknown;
const { paired } = parsePairingList(res);
return paired.map((n) => ({
nodeId: n.nodeId,
displayName: n.displayName,
platform: n.platform,
remoteIp: n.remoteIp,
}));
}
}
function pickDefaultNode(nodes: NodeListNode[]): NodeListNode | null {
const withCanvas = nodes.filter((n) =>
Array.isArray(n.caps) ? n.caps.includes("canvas") : true,
);
if (withCanvas.length === 0) return null;
const connected = withCanvas.filter((n) => n.connected);
const candidates = connected.length > 0 ? connected : withCanvas;
if (candidates.length === 1) return candidates[0];
const local = candidates.filter(
(n) =>
n.platform?.toLowerCase().startsWith("mac") &&
typeof n.nodeId === "string" &&
n.nodeId.startsWith("mac-"),
);
if (local.length === 1) return local[0];
return null;
}
export async function resolveNodeId(
opts: GatewayCallOptions,
query?: string,
allowDefault = false,
) {
const nodes = await loadNodes(opts);
const q = String(query ?? "").trim();
if (!q) {
if (allowDefault) {
const picked = pickDefaultNode(nodes);
if (picked) return picked.nodeId;
}
throw new Error("node required");
}
const qNorm = normalizeNodeKey(q);
const matches = nodes.filter((n) => {
if (n.nodeId === q) return true;
if (typeof n.remoteIp === "string" && n.remoteIp === q) return true;
const name = typeof n.displayName === "string" ? n.displayName : "";
if (name && normalizeNodeKey(name) === qNorm) return true;
if (q.length >= 6 && n.nodeId.startsWith(q)) return true;
return false;
});
if (matches.length === 1) return matches[0].nodeId;
if (matches.length === 0) {
const known = nodes
.map((n) => n.displayName || n.remoteIp || n.nodeId)
.filter(Boolean)
.join(", ");
throw new Error(`unknown node: ${q}${known ? ` (known: ${known})` : ""}`);
}
throw new Error(
`ambiguous node: ${q} (matches: ${matches
.map((n) => n.displayName || n.remoteIp || n.nodeId)
.join(", ")})`,
);
}
+105
View File
@@ -0,0 +1,105 @@
import type { ClawdisConfig } from "../../config/config.js";
export type SessionKind = "main" | "group" | "cron" | "hook" | "node" | "other";
function normalizeKey(value?: string) {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
export function resolveMainSessionAlias(cfg: ClawdisConfig) {
const mainKey = normalizeKey(cfg.session?.mainKey) ?? "main";
const scope = cfg.session?.scope ?? "per-sender";
const alias = scope === "global" ? "global" : mainKey;
return { mainKey, alias, scope };
}
export function resolveDisplaySessionKey(params: {
key: string;
alias: string;
mainKey: string;
}) {
if (params.key === params.alias) return "main";
if (params.key === params.mainKey) return "main";
return params.key;
}
export function resolveInternalSessionKey(params: {
key: string;
alias: string;
mainKey: string;
}) {
if (params.key === "main") return params.alias;
return params.key;
}
export function classifySessionKind(params: {
key: string;
gatewayKind?: string | null;
alias: string;
mainKey: string;
}): SessionKind {
const key = params.key;
if (key === params.alias || key === params.mainKey) return "main";
if (key.startsWith("cron:")) return "cron";
if (key.startsWith("hook:")) return "hook";
if (key.startsWith("node-") || key.startsWith("node:")) return "node";
if (params.gatewayKind === "group") return "group";
if (
key.startsWith("group:") ||
key.includes(":group:") ||
key.includes(":channel:")
) {
return "group";
}
return "other";
}
export function deriveProvider(params: {
key: string;
kind: SessionKind;
surface?: string | null;
lastChannel?: string | null;
}): string {
if (
params.kind === "cron" ||
params.kind === "hook" ||
params.kind === "node"
)
return "internal";
const surface = normalizeKey(params.surface ?? undefined);
if (surface) return surface;
const lastChannel = normalizeKey(params.lastChannel ?? undefined);
if (lastChannel) return lastChannel;
const parts = params.key.split(":").filter(Boolean);
if (parts.length >= 3 && (parts[1] === "group" || parts[1] === "channel")) {
return parts[0];
}
return "unknown";
}
export function stripToolMessages(messages: unknown[]): unknown[] {
return messages.filter((msg) => {
if (!msg || typeof msg !== "object") return true;
const role = (msg as { role?: unknown }).role;
return role !== "toolResult";
});
}
export function extractAssistantText(message: unknown): string | undefined {
if (!message || typeof message !== "object") return undefined;
if ((message as { role?: unknown }).role !== "assistant") return undefined;
const content = (message as { content?: unknown }).content;
if (!Array.isArray(content)) return undefined;
const chunks: string[] = [];
for (const block of content) {
if (!block || typeof block !== "object") continue;
if ((block as { type?: unknown }).type !== "text") continue;
const text = (block as { text?: unknown }).text;
if (typeof text === "string" && text.trim()) {
chunks.push(text);
}
}
const joined = chunks.join("").trim();
return joined ? joined : undefined;
}
+63
View File
@@ -0,0 +1,63 @@
import { Type } from "@sinclair/typebox";
import { loadConfig } from "../../config/config.js";
import { callGateway } from "../../gateway/call.js";
import type { AnyAgentTool } from "./common.js";
import { jsonResult, readStringParam } from "./common.js";
import {
resolveDisplaySessionKey,
resolveInternalSessionKey,
resolveMainSessionAlias,
stripToolMessages,
} from "./sessions-helpers.js";
const SessionsHistoryToolSchema = Type.Object({
sessionKey: Type.String(),
limit: Type.Optional(Type.Integer({ minimum: 1 })),
includeTools: Type.Optional(Type.Boolean()),
});
export function createSessionsHistoryTool(): AnyAgentTool {
return {
label: "Session History",
name: "sessions_history",
description: "Fetch message history for a session.",
parameters: SessionsHistoryToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const sessionKey = readStringParam(params, "sessionKey", {
required: true,
});
const cfg = loadConfig();
const { mainKey, alias } = resolveMainSessionAlias(cfg);
const resolvedKey = resolveInternalSessionKey({
key: sessionKey,
alias,
mainKey,
});
const limit =
typeof params.limit === "number" && Number.isFinite(params.limit)
? Math.max(1, Math.floor(params.limit))
: undefined;
const includeTools = Boolean(params.includeTools);
const result = (await callGateway({
method: "chat.history",
params: { sessionKey: resolvedKey, limit },
})) as { messages?: unknown[] };
const rawMessages = Array.isArray(result?.messages)
? result.messages
: [];
const messages = includeTools
? rawMessages
: stripToolMessages(rawMessages);
return jsonResult({
sessionKey: resolveDisplaySessionKey({
key: sessionKey,
alias,
mainKey,
}),
messages,
});
},
};
}
+209
View File
@@ -0,0 +1,209 @@
import path from "node:path";
import { Type } from "@sinclair/typebox";
import { loadConfig } from "../../config/config.js";
import { callGateway } from "../../gateway/call.js";
import type { AnyAgentTool } from "./common.js";
import { jsonResult, readStringArrayParam } from "./common.js";
import {
classifySessionKind,
deriveProvider,
resolveDisplaySessionKey,
resolveInternalSessionKey,
resolveMainSessionAlias,
type SessionKind,
stripToolMessages,
} from "./sessions-helpers.js";
type SessionListRow = {
key: string;
kind: SessionKind;
provider: string;
displayName?: string;
updatedAt?: number | null;
sessionId?: string;
model?: string;
contextTokens?: number | null;
totalTokens?: number | null;
thinkingLevel?: string;
verboseLevel?: string;
systemSent?: boolean;
abortedLastRun?: boolean;
sendPolicy?: string;
lastChannel?: string;
lastTo?: string;
transcriptPath?: string;
messages?: unknown[];
};
const SessionsListToolSchema = Type.Object({
kinds: Type.Optional(Type.Array(Type.String())),
limit: Type.Optional(Type.Integer({ minimum: 1 })),
activeMinutes: Type.Optional(Type.Integer({ minimum: 1 })),
messageLimit: Type.Optional(Type.Integer({ minimum: 0 })),
});
export function createSessionsListTool(): AnyAgentTool {
return {
label: "Sessions",
name: "sessions_list",
description: "List sessions with optional filters and last messages.",
parameters: SessionsListToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const cfg = loadConfig();
const { mainKey, alias } = resolveMainSessionAlias(cfg);
const kindsRaw = readStringArrayParam(params, "kinds")?.map((value) =>
value.trim().toLowerCase(),
);
const allowedKindsList = (kindsRaw ?? []).filter((value) =>
["main", "group", "cron", "hook", "node", "other"].includes(value),
);
const allowedKinds = allowedKindsList.length
? new Set(allowedKindsList)
: undefined;
const limit =
typeof params.limit === "number" && Number.isFinite(params.limit)
? Math.max(1, Math.floor(params.limit))
: undefined;
const activeMinutes =
typeof params.activeMinutes === "number" &&
Number.isFinite(params.activeMinutes)
? Math.max(1, Math.floor(params.activeMinutes))
: undefined;
const messageLimitRaw =
typeof params.messageLimit === "number" &&
Number.isFinite(params.messageLimit)
? Math.max(0, Math.floor(params.messageLimit))
: 0;
const messageLimit = Math.min(messageLimitRaw, 20);
const list = (await callGateway({
method: "sessions.list",
params: {
limit,
activeMinutes,
includeGlobal: true,
includeUnknown: true,
},
})) as {
path?: string;
sessions?: Array<Record<string, unknown>>;
};
const sessions = Array.isArray(list?.sessions) ? list.sessions : [];
const storePath = typeof list?.path === "string" ? list.path : undefined;
const rows: SessionListRow[] = [];
for (const entry of sessions) {
if (!entry || typeof entry !== "object") continue;
const key = typeof entry.key === "string" ? entry.key : "";
if (!key) continue;
if (key === "unknown") continue;
if (key === "global" && alias !== "global") continue;
const gatewayKind =
typeof entry.kind === "string" ? entry.kind : undefined;
const kind = classifySessionKind({ key, gatewayKind, alias, mainKey });
if (allowedKinds && !allowedKinds.has(kind)) continue;
const displayKey = resolveDisplaySessionKey({
key,
alias,
mainKey,
});
const surface =
typeof entry.surface === "string" ? entry.surface : undefined;
const lastChannel =
typeof entry.lastChannel === "string" ? entry.lastChannel : undefined;
const provider = deriveProvider({
key,
kind,
surface,
lastChannel,
});
const sessionId =
typeof entry.sessionId === "string" ? entry.sessionId : undefined;
const transcriptPath =
sessionId && storePath
? path.join(path.dirname(storePath), `${sessionId}.jsonl`)
: undefined;
const row: SessionListRow = {
key: displayKey,
kind,
provider,
displayName:
typeof entry.displayName === "string"
? entry.displayName
: undefined,
updatedAt:
typeof entry.updatedAt === "number" ? entry.updatedAt : undefined,
sessionId,
model: typeof entry.model === "string" ? entry.model : undefined,
contextTokens:
typeof entry.contextTokens === "number"
? entry.contextTokens
: undefined,
totalTokens:
typeof entry.totalTokens === "number"
? entry.totalTokens
: undefined,
thinkingLevel:
typeof entry.thinkingLevel === "string"
? entry.thinkingLevel
: undefined,
verboseLevel:
typeof entry.verboseLevel === "string"
? entry.verboseLevel
: undefined,
systemSent:
typeof entry.systemSent === "boolean"
? entry.systemSent
: undefined,
abortedLastRun:
typeof entry.abortedLastRun === "boolean"
? entry.abortedLastRun
: undefined,
sendPolicy:
typeof entry.sendPolicy === "string" ? entry.sendPolicy : undefined,
lastChannel,
lastTo: typeof entry.lastTo === "string" ? entry.lastTo : undefined,
transcriptPath,
};
if (messageLimit > 0) {
const resolvedKey = resolveInternalSessionKey({
key: displayKey,
alias,
mainKey,
});
const history = (await callGateway({
method: "chat.history",
params: { sessionKey: resolvedKey, limit: messageLimit },
})) as { messages?: unknown[] };
const rawMessages = Array.isArray(history?.messages)
? history.messages
: [];
const filtered = stripToolMessages(rawMessages);
row.messages =
filtered.length > messageLimit
? filtered.slice(-messageLimit)
: filtered;
}
rows.push(row);
}
return jsonResult({
count: rows.length,
sessions: rows,
});
},
};
}
+132
View File
@@ -0,0 +1,132 @@
import type { ClawdisConfig } from "../../config/config.js";
const ANNOUNCE_SKIP_TOKEN = "ANNOUNCE_SKIP";
const REPLY_SKIP_TOKEN = "REPLY_SKIP";
const DEFAULT_PING_PONG_TURNS = 5;
const MAX_PING_PONG_TURNS = 5;
export type AnnounceTarget = {
channel: string;
to: string;
};
export function resolveAnnounceTargetFromKey(
sessionKey: string,
): AnnounceTarget | null {
const parts = sessionKey.split(":").filter(Boolean);
if (parts.length < 3) return null;
const [surface, kind, ...rest] = parts;
if (kind !== "group" && kind !== "channel") return null;
const id = rest.join(":").trim();
if (!id) return null;
if (!surface) return null;
const channel = surface.toLowerCase();
if (channel === "discord") {
return { channel, to: `channel:${id}` };
}
if (channel === "signal") {
return { channel, to: `group:${id}` };
}
return { channel, to: id };
}
export function buildAgentToAgentMessageContext(params: {
requesterSessionKey?: string;
requesterSurface?: string;
targetSessionKey: string;
}) {
const lines = [
"Agent-to-agent message context:",
params.requesterSessionKey
? `Agent 1 (requester) session: ${params.requesterSessionKey}.`
: undefined,
params.requesterSurface
? `Agent 1 (requester) surface: ${params.requesterSurface}.`
: undefined,
`Agent 2 (target) session: ${params.targetSessionKey}.`,
].filter(Boolean);
return lines.join("\n");
}
export function buildAgentToAgentReplyContext(params: {
requesterSessionKey?: string;
requesterSurface?: string;
targetSessionKey: string;
targetChannel?: string;
currentRole: "requester" | "target";
turn: number;
maxTurns: number;
}) {
const currentLabel =
params.currentRole === "requester"
? "Agent 1 (requester)"
: "Agent 2 (target)";
const lines = [
"Agent-to-agent reply step:",
`Current agent: ${currentLabel}.`,
`Turn ${params.turn} of ${params.maxTurns}.`,
params.requesterSessionKey
? `Agent 1 (requester) session: ${params.requesterSessionKey}.`
: undefined,
params.requesterSurface
? `Agent 1 (requester) surface: ${params.requesterSurface}.`
: undefined,
`Agent 2 (target) session: ${params.targetSessionKey}.`,
params.targetChannel
? `Agent 2 (target) surface: ${params.targetChannel}.`
: undefined,
`If you want to stop the ping-pong, reply exactly "${REPLY_SKIP_TOKEN}".`,
].filter(Boolean);
return lines.join("\n");
}
export function buildAgentToAgentAnnounceContext(params: {
requesterSessionKey?: string;
requesterSurface?: string;
targetSessionKey: string;
targetChannel?: string;
originalMessage: string;
roundOneReply?: string;
latestReply?: string;
}) {
const lines = [
"Agent-to-agent announce step:",
params.requesterSessionKey
? `Agent 1 (requester) session: ${params.requesterSessionKey}.`
: undefined,
params.requesterSurface
? `Agent 1 (requester) surface: ${params.requesterSurface}.`
: undefined,
`Agent 2 (target) session: ${params.targetSessionKey}.`,
params.targetChannel
? `Agent 2 (target) surface: ${params.targetChannel}.`
: undefined,
`Original request: ${params.originalMessage}`,
params.roundOneReply
? `Round 1 reply: ${params.roundOneReply}`
: "Round 1 reply: (not available).",
params.latestReply
? `Latest reply: ${params.latestReply}`
: "Latest reply: (not available).",
`If you want to remain silent, reply exactly "${ANNOUNCE_SKIP_TOKEN}".`,
"Any other reply will be posted to the target channel.",
"After this reply, the agent-to-agent conversation is over.",
].filter(Boolean);
return lines.join("\n");
}
export function isAnnounceSkip(text?: string) {
return (text ?? "").trim() === ANNOUNCE_SKIP_TOKEN;
}
export function isReplySkip(text?: string) {
return (text ?? "").trim() === REPLY_SKIP_TOKEN;
}
export function resolvePingPongTurns(cfg?: ClawdisConfig) {
const raw = cfg?.session?.agentToAgent?.maxPingPongTurns;
const fallback = DEFAULT_PING_PONG_TURNS;
if (typeof raw !== "number" || !Number.isFinite(raw)) return fallback;
const rounded = Math.floor(raw);
return Math.max(0, Math.min(MAX_PING_PONG_TURNS, rounded));
}
+403
View File
@@ -0,0 +1,403 @@
import crypto from "node:crypto";
import { Type } from "@sinclair/typebox";
import { loadConfig } from "../../config/config.js";
import { callGateway } from "../../gateway/call.js";
import type { AnyAgentTool } from "./common.js";
import { jsonResult, readStringParam } from "./common.js";
import {
extractAssistantText,
resolveDisplaySessionKey,
resolveInternalSessionKey,
resolveMainSessionAlias,
stripToolMessages,
} from "./sessions-helpers.js";
import {
type AnnounceTarget,
buildAgentToAgentAnnounceContext,
buildAgentToAgentMessageContext,
buildAgentToAgentReplyContext,
isAnnounceSkip,
isReplySkip,
resolveAnnounceTargetFromKey,
resolvePingPongTurns,
} from "./sessions-send-helpers.js";
const SessionsSendToolSchema = Type.Object({
sessionKey: Type.String(),
message: Type.String(),
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 0 })),
});
export function createSessionsSendTool(opts?: {
agentSessionKey?: string;
agentSurface?: string;
}): AnyAgentTool {
return {
label: "Session Send",
name: "sessions_send",
description: "Send a message into another session.",
parameters: SessionsSendToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const sessionKey = readStringParam(params, "sessionKey", {
required: true,
});
const message = readStringParam(params, "message", { required: true });
const cfg = loadConfig();
const { mainKey, alias } = resolveMainSessionAlias(cfg);
const resolvedKey = resolveInternalSessionKey({
key: sessionKey,
alias,
mainKey,
});
const timeoutSeconds =
typeof params.timeoutSeconds === "number" &&
Number.isFinite(params.timeoutSeconds)
? Math.max(0, Math.floor(params.timeoutSeconds))
: 30;
const timeoutMs = timeoutSeconds * 1000;
const announceTimeoutMs = timeoutSeconds === 0 ? 30_000 : timeoutMs;
const idempotencyKey = crypto.randomUUID();
let runId: string = idempotencyKey;
const displayKey = resolveDisplaySessionKey({
key: sessionKey,
alias,
mainKey,
});
const agentMessageContext = buildAgentToAgentMessageContext({
requesterSessionKey: opts?.agentSessionKey,
requesterSurface: opts?.agentSurface,
targetSessionKey: displayKey,
});
const sendParams = {
message,
sessionKey: resolvedKey,
idempotencyKey,
deliver: false,
lane: "nested",
extraSystemPrompt: agentMessageContext,
};
const requesterSessionKey = opts?.agentSessionKey;
const requesterSurface = opts?.agentSurface;
const maxPingPongTurns = resolvePingPongTurns(cfg);
const resolveAnnounceTarget =
async (): Promise<AnnounceTarget | null> => {
const parsed = resolveAnnounceTargetFromKey(resolvedKey);
if (parsed) return parsed;
try {
const list = (await callGateway({
method: "sessions.list",
params: {
includeGlobal: true,
includeUnknown: true,
limit: 200,
},
})) as { sessions?: Array<Record<string, unknown>> };
const sessions = Array.isArray(list?.sessions) ? list.sessions : [];
const match =
sessions.find((entry) => entry?.key === resolvedKey) ??
sessions.find((entry) => entry?.key === displayKey);
const channel =
typeof match?.lastChannel === "string"
? match.lastChannel
: undefined;
const to =
typeof match?.lastTo === "string" ? match.lastTo : undefined;
if (channel && to) return { channel, to };
} catch {
// ignore; fall through to null
}
return null;
};
const readLatestAssistantReply = async (
sessionKeyToRead: string,
): Promise<string | undefined> => {
const history = (await callGateway({
method: "chat.history",
params: { sessionKey: sessionKeyToRead, limit: 50 },
})) as { messages?: unknown[] };
const filtered = stripToolMessages(
Array.isArray(history?.messages) ? history.messages : [],
);
const last =
filtered.length > 0 ? filtered[filtered.length - 1] : undefined;
return last ? extractAssistantText(last) : undefined;
};
const runAgentStep = async (step: {
sessionKey: string;
message: string;
extraSystemPrompt: string;
timeoutMs: number;
}): Promise<string | undefined> => {
const stepIdem = crypto.randomUUID();
const response = (await callGateway({
method: "agent",
params: {
message: step.message,
sessionKey: step.sessionKey,
idempotencyKey: stepIdem,
deliver: false,
lane: "nested",
extraSystemPrompt: step.extraSystemPrompt,
},
timeoutMs: 10_000,
})) as { runId?: string; acceptedAt?: number };
const stepRunId =
typeof response?.runId === "string" && response.runId
? response.runId
: stepIdem;
const stepAcceptedAt =
typeof response?.acceptedAt === "number"
? response.acceptedAt
: undefined;
const stepWaitMs = Math.min(step.timeoutMs, 60_000);
const wait = (await callGateway({
method: "agent.wait",
params: {
runId: stepRunId,
afterMs: stepAcceptedAt,
timeoutMs: stepWaitMs,
},
timeoutMs: stepWaitMs + 2000,
})) as { status?: string };
if (wait?.status !== "ok") return undefined;
return readLatestAssistantReply(step.sessionKey);
};
const runAgentToAgentFlow = async (
roundOneReply?: string,
runInfo?: { runId: string; acceptedAt?: number },
) => {
try {
let primaryReply = roundOneReply;
let latestReply = roundOneReply;
if (!primaryReply && runInfo?.runId) {
const waitMs = Math.min(announceTimeoutMs, 60_000);
const wait = (await callGateway({
method: "agent.wait",
params: {
runId: runInfo.runId,
afterMs: runInfo.acceptedAt,
timeoutMs: waitMs,
},
timeoutMs: waitMs + 2000,
})) as { status?: string };
if (wait?.status === "ok") {
primaryReply = await readLatestAssistantReply(resolvedKey);
latestReply = primaryReply;
}
}
if (!latestReply) return;
const announceTarget = await resolveAnnounceTarget();
const targetChannel = announceTarget?.channel ?? "unknown";
if (
maxPingPongTurns > 0 &&
requesterSessionKey &&
requesterSessionKey !== resolvedKey
) {
let currentSessionKey = requesterSessionKey;
let nextSessionKey = resolvedKey;
let incomingMessage = latestReply;
for (let turn = 1; turn <= maxPingPongTurns; turn += 1) {
const currentRole =
currentSessionKey === requesterSessionKey
? "requester"
: "target";
const replyPrompt = buildAgentToAgentReplyContext({
requesterSessionKey,
requesterSurface,
targetSessionKey: displayKey,
targetChannel,
currentRole,
turn,
maxTurns: maxPingPongTurns,
});
const replyText = await runAgentStep({
sessionKey: currentSessionKey,
message: incomingMessage,
extraSystemPrompt: replyPrompt,
timeoutMs: announceTimeoutMs,
});
if (!replyText || isReplySkip(replyText)) {
break;
}
latestReply = replyText;
incomingMessage = replyText;
const swap = currentSessionKey;
currentSessionKey = nextSessionKey;
nextSessionKey = swap;
}
}
const announcePrompt = buildAgentToAgentAnnounceContext({
requesterSessionKey,
requesterSurface,
targetSessionKey: displayKey,
targetChannel,
originalMessage: message,
roundOneReply: primaryReply,
latestReply,
});
const announceReply = await runAgentStep({
sessionKey: resolvedKey,
message: "Agent-to-agent announce step.",
extraSystemPrompt: announcePrompt,
timeoutMs: announceTimeoutMs,
});
if (
announceTarget &&
announceReply &&
announceReply.trim() &&
!isAnnounceSkip(announceReply)
) {
await callGateway({
method: "send",
params: {
to: announceTarget.to,
message: announceReply.trim(),
provider: announceTarget.channel,
idempotencyKey: crypto.randomUUID(),
},
timeoutMs: 10_000,
});
}
} catch {
// Best-effort follow-ups; ignore failures to avoid breaking the caller response.
}
};
if (timeoutSeconds === 0) {
try {
const response = (await callGateway({
method: "agent",
params: sendParams,
timeoutMs: 10_000,
})) as { runId?: string; acceptedAt?: number };
const acceptedAt =
typeof response?.acceptedAt === "number"
? response.acceptedAt
: undefined;
if (typeof response?.runId === "string" && response.runId) {
runId = response.runId;
}
void runAgentToAgentFlow(undefined, { runId, acceptedAt });
return jsonResult({
runId,
status: "accepted",
sessionKey: displayKey,
});
} catch (err) {
const messageText =
err instanceof Error
? err.message
: typeof err === "string"
? err
: "error";
return jsonResult({
runId,
status: "error",
error: messageText,
sessionKey: displayKey,
});
}
}
let acceptedAt: number | undefined;
try {
const response = (await callGateway({
method: "agent",
params: sendParams,
timeoutMs: 10_000,
})) as { runId?: string; acceptedAt?: number };
if (typeof response?.runId === "string" && response.runId) {
runId = response.runId;
}
if (typeof response?.acceptedAt === "number") {
acceptedAt = response.acceptedAt;
}
} catch (err) {
const messageText =
err instanceof Error
? err.message
: typeof err === "string"
? err
: "error";
return jsonResult({
runId,
status: "error",
error: messageText,
sessionKey: displayKey,
});
}
let waitStatus: string | undefined;
let waitError: string | undefined;
try {
const wait = (await callGateway({
method: "agent.wait",
params: {
runId,
afterMs: acceptedAt,
timeoutMs,
},
timeoutMs: timeoutMs + 2000,
})) as { status?: string; error?: string };
waitStatus = typeof wait?.status === "string" ? wait.status : undefined;
waitError = typeof wait?.error === "string" ? wait.error : undefined;
} catch (err) {
const messageText =
err instanceof Error
? err.message
: typeof err === "string"
? err
: "error";
return jsonResult({
runId,
status: messageText.includes("gateway timeout") ? "timeout" : "error",
error: messageText,
sessionKey: displayKey,
});
}
if (waitStatus === "timeout") {
return jsonResult({
runId,
status: "timeout",
error: waitError,
sessionKey: displayKey,
});
}
if (waitStatus === "error") {
return jsonResult({
runId,
status: "error",
error: waitError ?? "agent error",
sessionKey: displayKey,
});
}
const history = (await callGateway({
method: "chat.history",
params: { sessionKey: resolvedKey, limit: 50 },
})) as { messages?: unknown[] };
const filtered = stripToolMessages(
Array.isArray(history?.messages) ? history.messages : [],
);
const last =
filtered.length > 0 ? filtered[filtered.length - 1] : undefined;
const reply = last ? extractAssistantText(last) : undefined;
void runAgentToAgentFlow(reply ?? undefined);
return jsonResult({
runId,
status: "ok",
reply,
sessionKey: displayKey,
});
},
};
}
+84
View File
@@ -13,6 +13,7 @@ import {
} from "../config/sessions.js";
import { drainSystemEvents } from "../infra/system-events.js";
import {
extractElevatedDirective,
extractQueueDirective,
extractReplyToTag,
extractThinkDirective,
@@ -85,6 +86,12 @@ describe("directive parsing", () => {
expect(res.verboseLevel).toBe("on");
});
it("matches elevated with leading space", () => {
const res = extractElevatedDirective(" please /elevated on now");
expect(res.hasDirective).toBe(true);
expect(res.elevatedLevel).toBe("on");
});
it("matches think at start of line", () => {
const res = extractThinkDirective("/think:high run slow");
expect(res.hasDirective).toBe(true);
@@ -292,6 +299,38 @@ describe("directive parsing", () => {
});
});
it("rejects invalid elevated level", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const res = await getReplyFromConfig(
{
Body: "/elevated maybe",
From: "+1222",
To: "+1222",
Surface: "whatsapp",
SenderE164: "+1222",
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
elevated: {
allowFrom: { whatsapp: ["+1222"] },
},
},
whatsapp: { allowFrom: ["+1222"] },
session: { store: path.join(home, "sessions.json") },
},
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Unrecognized elevated level");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("acks queue directive and persists override", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
@@ -744,4 +783,49 @@ describe("directive parsing", () => {
expect(call?.thinkLevel).toBe("low");
});
});
it("passes elevated defaults when sender is approved", async () => {
await withTempHome(async (home) => {
const storePath = path.join(home, "sessions.json");
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "done" }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
await getReplyFromConfig(
{
Body: "hello",
From: "+1004",
To: "+2000",
Surface: "whatsapp",
SenderE164: "+1004",
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
elevated: {
allowFrom: { whatsapp: ["+1004"] },
},
},
whatsapp: {
allowFrom: ["*"],
},
session: { store: storePath },
},
);
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
const call = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0];
expect(call?.bashElevated).toEqual({
enabled: true,
allowed: true,
defaultLevel: "on",
});
});
});
});
+187
View File
@@ -143,6 +143,193 @@ describe("trigger handling", () => {
});
});
it("allows approved sender to toggle elevated mode", async () => {
await withTempHome(async (home) => {
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
elevated: {
allowFrom: { whatsapp: ["+1000"] },
},
},
whatsapp: {
allowFrom: ["+1000"],
},
session: { store: join(home, "sessions.json") },
};
const res = await getReplyFromConfig(
{
Body: "/elevated on",
From: "+1000",
To: "+2000",
Surface: "whatsapp",
SenderE164: "+1000",
},
{},
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Elevated mode enabled");
const storeRaw = await fs.readFile(cfg.session.store, "utf-8");
const store = JSON.parse(storeRaw) as Record<
string,
{ elevatedLevel?: string }
>;
expect(store.main?.elevatedLevel).toBe("on");
});
});
it("rejects elevated toggles when disabled", async () => {
await withTempHome(async (home) => {
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
elevated: {
enabled: false,
allowFrom: { whatsapp: ["+1000"] },
},
},
whatsapp: {
allowFrom: ["+1000"],
},
session: { store: join(home, "sessions.json") },
};
const res = await getReplyFromConfig(
{
Body: "/elevated on",
From: "+1000",
To: "+2000",
Surface: "whatsapp",
SenderE164: "+1000",
},
{},
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("elevated is not available right now.");
const storeRaw = await fs.readFile(cfg.session.store, "utf-8");
const store = JSON.parse(storeRaw) as Record<
string,
{ elevatedLevel?: string }
>;
expect(store.main?.elevatedLevel).toBeUndefined();
});
});
it("rejects elevated inline directive for unapproved sender", async () => {
await withTempHome(async (home) => {
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
elevated: {
allowFrom: { whatsapp: ["+1000"] },
},
},
whatsapp: {
allowFrom: ["+1000"],
},
session: { store: join(home, "sessions.json") },
};
const res = await getReplyFromConfig(
{
Body: "please /elevated on now",
From: "+2000",
To: "+2000",
Surface: "whatsapp",
SenderE164: "+2000",
},
{},
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("elevated is not available right now.");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("falls back to discord dm allowFrom for elevated approval", async () => {
await withTempHome(async (home) => {
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
discord: {
dm: {
allowFrom: ["steipete"],
},
},
session: { store: join(home, "sessions.json") },
};
const res = await getReplyFromConfig(
{
Body: "/elevated on",
From: "discord:123",
To: "user:123",
Surface: "discord",
SenderName: "Peter Steinberger",
SenderUsername: "steipete",
SenderTag: "steipete",
},
{},
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Elevated mode enabled");
const storeRaw = await fs.readFile(cfg.session.store, "utf-8");
const store = JSON.parse(storeRaw) as Record<
string,
{ elevatedLevel?: string }
>;
expect(store.main?.elevatedLevel).toBe("on");
});
});
it("treats explicit discord elevated allowlist as override", async () => {
await withTempHome(async (home) => {
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
elevated: {
allowFrom: { discord: [] },
},
},
discord: {
dm: {
allowFrom: ["steipete"],
},
},
session: { store: join(home, "sessions.json") },
};
const res = await getReplyFromConfig(
{
Body: "/elevated on",
From: "discord:123",
To: "user:123",
Surface: "discord",
SenderName: "steipete",
},
{},
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("elevated is not available right now.");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("returns a context overflow fallback when the embedded agent throws", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockRejectedValue(
+405 -2226
View File
File diff suppressed because it is too large. Load diff
+16
View File
@@ -0,0 +1,16 @@
const ABORT_TRIGGERS = new Set(["stop", "esc", "abort", "wait", "exit"]);
const ABORT_MEMORY = new Map<string, boolean>();
export function isAbortTrigger(text?: string): boolean {
if (!text) return false;
const normalized = text.trim().toLowerCase();
return ABORT_TRIGGERS.has(normalized);
}
export function getAbortMemory(key: string): boolean | undefined {
return ABORT_MEMORY.get(key);
}
export function setAbortMemory(key: string, value: boolean): void {
ABORT_MEMORY.set(key, value);
}
+450
View File
@@ -0,0 +1,450 @@
import crypto from "node:crypto";
import { lookupContextTokens } from "../../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
import {
queueEmbeddedPiMessage,
runEmbeddedPiAgent,
} from "../../agents/pi-embedded.js";
import {
loadSessionStore,
type SessionEntry,
saveSessionStore,
} from "../../config/sessions.js";
import { logVerbose } from "../../globals.js";
import { registerAgentRunContext } from "../../infra/agent-events.js";
import { defaultRuntime } from "../../runtime.js";
import { stripHeartbeatToken } from "../heartbeat.js";
import type { TemplateContext } from "../templating.js";
import { normalizeVerboseLevel, type VerboseLevel } from "../thinking.js";
import { SILENT_REPLY_TOKEN } from "../tokens.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js";
import { createFollowupRunner } from "./followup-runner.js";
import {
enqueueFollowupRun,
type FollowupRun,
type QueueSettings,
scheduleFollowupDrain,
} from "./queue.js";
import { extractReplyToTag } from "./reply-tags.js";
import type { TypingController } from "./typing.js";
export async function runReplyAgent(params: {
commandBody: string;
followupRun: FollowupRun;
queueKey: string;
resolvedQueue: QueueSettings;
shouldSteer: boolean;
shouldFollowup: boolean;
isActive: boolean;
isStreaming: boolean;
opts?: GetReplyOptions;
typing: TypingController;
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionKey?: string;
storePath?: string;
defaultModel: string;
agentCfgContextTokens?: number;
resolvedVerboseLevel: VerboseLevel;
isNewSession: boolean;
blockStreamingEnabled: boolean;
blockReplyChunking?: {
minChars: number;
maxChars: number;
breakPreference: "paragraph" | "newline" | "sentence";
};
resolvedBlockStreamingBreak: "text_end" | "message_end";
sessionCtx: TemplateContext;
shouldInjectGroupIntro: boolean;
}): Promise<ReplyPayload | ReplyPayload[] | undefined> {
const {
commandBody,
followupRun,
queueKey,
resolvedQueue,
shouldSteer,
shouldFollowup,
isActive,
isStreaming,
opts,
typing,
sessionEntry,
sessionStore,
sessionKey,
storePath,
defaultModel,
agentCfgContextTokens,
resolvedVerboseLevel,
isNewSession,
blockStreamingEnabled,
blockReplyChunking,
resolvedBlockStreamingBreak,
sessionCtx,
shouldInjectGroupIntro,
} = params;
const shouldEmitToolResult = () => {
if (!sessionKey || !storePath) {
return resolvedVerboseLevel === "on";
}
try {
const store = loadSessionStore(storePath);
const entry = store[sessionKey];
const current = normalizeVerboseLevel(entry?.verboseLevel);
if (current) return current === "on";
} catch {
// ignore store read failures
}
return resolvedVerboseLevel === "on";
};
const streamedPayloadKeys = new Set<string>();
const pendingStreamedPayloadKeys = new Set<string>();
const pendingBlockTasks = new Set<Promise<void>>();
let didStreamBlockReply = false;
const buildPayloadKey = (payload: ReplyPayload) => {
const text = payload.text?.trim() ?? "";
const mediaList = payload.mediaUrls?.length
? payload.mediaUrls
: payload.mediaUrl
? [payload.mediaUrl]
: [];
return JSON.stringify({
text,
mediaList,
replyToId: payload.replyToId ?? null,
});
};
if (shouldSteer && isStreaming) {
const steered = queueEmbeddedPiMessage(
followupRun.run.sessionId,
followupRun.prompt,
);
if (steered && !shouldFollowup) {
if (sessionEntry && sessionStore && sessionKey) {
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await saveSessionStore(storePath, sessionStore);
}
}
typing.cleanup();
return undefined;
}
}
if (isActive && (shouldFollowup || resolvedQueue.mode === "steer")) {
enqueueFollowupRun(queueKey, followupRun, resolvedQueue);
if (sessionEntry && sessionStore && sessionKey) {
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await saveSessionStore(storePath, sessionStore);
}
}
typing.cleanup();
return undefined;
}
const runFollowupTurn = createFollowupRunner({
opts,
typing,
sessionEntry,
sessionStore,
sessionKey,
storePath,
defaultModel,
agentCfgContextTokens,
});
const finalizeWithFollowup = <T>(value: T): T => {
scheduleFollowupDrain(queueKey, runFollowupTurn);
return value;
};
let didLogHeartbeatStrip = false;
try {
const runId = crypto.randomUUID();
if (sessionKey) {
registerAgentRunContext(runId, { sessionKey });
}
let runResult: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
try {
runResult = await runEmbeddedPiAgent({
sessionId: followupRun.run.sessionId,
sessionKey,
surface: sessionCtx.Surface?.trim().toLowerCase() || undefined,
sessionFile: followupRun.run.sessionFile,
workspaceDir: followupRun.run.workspaceDir,
config: followupRun.run.config,
skillsSnapshot: followupRun.run.skillsSnapshot,
prompt: commandBody,
extraSystemPrompt: followupRun.run.extraSystemPrompt,
ownerNumbers: followupRun.run.ownerNumbers,
enforceFinalTag: followupRun.run.enforceFinalTag,
provider: followupRun.run.provider,
model: followupRun.run.model,
thinkLevel: followupRun.run.thinkLevel,
verboseLevel: followupRun.run.verboseLevel,
bashElevated: followupRun.run.bashElevated,
timeoutMs: followupRun.run.timeoutMs,
runId,
blockReplyBreak: resolvedBlockStreamingBreak,
blockReplyChunking,
onPartialReply: opts?.onPartialReply
? async (payload) => {
let text = payload.text;
if (!opts?.isHeartbeat && text?.includes("HEARTBEAT_OK")) {
const stripped = stripHeartbeatToken(text, { mode: "message" });
if (stripped.didStrip && !didLogHeartbeatStrip) {
didLogHeartbeatStrip = true;
logVerbose("Stripped stray HEARTBEAT_OK token from reply");
}
if (
stripped.shouldSkip &&
(payload.mediaUrls?.length ?? 0) === 0
) {
return;
}
text = stripped.text;
}
await typing.startTypingOnText(text);
await opts.onPartialReply?.({
text,
mediaUrls: payload.mediaUrls,
});
}
: undefined,
onBlockReply:
blockStreamingEnabled && opts?.onBlockReply
? async (payload) => {
let text = payload.text;
if (!opts?.isHeartbeat && text?.includes("HEARTBEAT_OK")) {
const stripped = stripHeartbeatToken(text, {
mode: "message",
});
if (stripped.didStrip && !didLogHeartbeatStrip) {
didLogHeartbeatStrip = true;
logVerbose("Stripped stray HEARTBEAT_OK token from reply");
}
const hasMedia = (payload.mediaUrls?.length ?? 0) > 0;
if (stripped.shouldSkip && !hasMedia) return;
text = stripped.text;
}
const tagResult = extractReplyToTag(
text,
sessionCtx.MessageSid,
);
const cleaned = tagResult.cleaned || undefined;
const hasMedia = (payload.mediaUrls?.length ?? 0) > 0;
if (!cleaned && !hasMedia) return;
if (cleaned?.trim() === SILENT_REPLY_TOKEN && !hasMedia) return;
const blockPayload: ReplyPayload = {
text: cleaned,
mediaUrls: payload.mediaUrls,
mediaUrl: payload.mediaUrls?.[0],
replyToId: tagResult.replyToId,
};
const payloadKey = buildPayloadKey(blockPayload);
if (
streamedPayloadKeys.has(payloadKey) ||
pendingStreamedPayloadKeys.has(payloadKey)
) {
return;
}
pendingStreamedPayloadKeys.add(payloadKey);
const task = (async () => {
await typing.startTypingOnText(cleaned);
await opts.onBlockReply?.(blockPayload);
})()
.then(() => {
streamedPayloadKeys.add(payloadKey);
didStreamBlockReply = true;
})
.catch((err) => {
logVerbose(`block reply delivery failed: ${String(err)}`);
})
.finally(() => {
pendingStreamedPayloadKeys.delete(payloadKey);
});
pendingBlockTasks.add(task);
void task.finally(() => pendingBlockTasks.delete(task));
}
: undefined,
shouldEmitToolResult,
onToolResult: opts?.onToolResult
? async (payload) => {
let text = payload.text;
if (!opts?.isHeartbeat && text?.includes("HEARTBEAT_OK")) {
const stripped = stripHeartbeatToken(text, { mode: "message" });
if (stripped.didStrip && !didLogHeartbeatStrip) {
didLogHeartbeatStrip = true;
logVerbose("Stripped stray HEARTBEAT_OK token from reply");
}
if (
stripped.shouldSkip &&
(payload.mediaUrls?.length ?? 0) === 0
) {
return;
}
text = stripped.text;
}
await typing.startTypingOnText(text);
await opts.onToolResult?.({ text, mediaUrls: payload.mediaUrls });
}
: undefined,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const isContextOverflow =
/context.*overflow|too large|context window/i.test(message);
defaultRuntime.error(`Embedded agent failed before reply: ${message}`);
return finalizeWithFollowup({
text: isContextOverflow
? "⚠️ Context overflow - conversation too long. Starting fresh might help!"
: `⚠️ Agent failed before reply: ${message}. Check gateway logs for details.`,
});
}
if (
shouldInjectGroupIntro &&
sessionEntry &&
sessionStore &&
sessionKey &&
sessionEntry.groupActivationNeedsSystemIntro
) {
sessionEntry.groupActivationNeedsSystemIntro = false;
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await saveSessionStore(storePath, sessionStore);
}
}
const payloadArray = runResult.payloads ?? [];
if (payloadArray.length === 0) return finalizeWithFollowup(undefined);
if (pendingBlockTasks.size > 0) {
await Promise.allSettled(pendingBlockTasks);
}
const sanitizedPayloads = opts?.isHeartbeat
? payloadArray
: payloadArray.flatMap((payload) => {
const text = payload.text;
if (!text || !text.includes("HEARTBEAT_OK")) return [payload];
const stripped = stripHeartbeatToken(text, { mode: "message" });
if (stripped.didStrip && !didLogHeartbeatStrip) {
didLogHeartbeatStrip = true;
logVerbose("Stripped stray HEARTBEAT_OK token from reply");
}
const hasMedia =
Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
if (stripped.shouldSkip && !hasMedia) return [];
return [{ ...payload, text: stripped.text }];
});
const replyTaggedPayloads: ReplyPayload[] = sanitizedPayloads
.map((payload) => {
const { cleaned, replyToId } = extractReplyToTag(
payload.text,
sessionCtx.MessageSid,
);
return {
...payload,
text: cleaned ? cleaned : undefined,
replyToId: replyToId ?? payload.replyToId,
};
})
.filter(
(payload) =>
payload.text ||
payload.mediaUrl ||
(payload.mediaUrls && payload.mediaUrls.length > 0),
);
const shouldDropFinalPayloads =
blockStreamingEnabled && didStreamBlockReply;
const filteredPayloads = shouldDropFinalPayloads
? []
: blockStreamingEnabled
? replyTaggedPayloads.filter(
(payload) => !streamedPayloadKeys.has(buildPayloadKey(payload)),
)
: replyTaggedPayloads;
if (filteredPayloads.length === 0) return finalizeWithFollowup(undefined);
const shouldSignalTyping = filteredPayloads.some((payload) => {
const trimmed = payload.text?.trim();
if (trimmed && trimmed !== SILENT_REPLY_TOKEN) return true;
if (payload.mediaUrl) return true;
if (payload.mediaUrls && payload.mediaUrls.length > 0) return true;
return false;
});
if (shouldSignalTyping) {
await typing.startTypingLoop();
}
if (sessionStore && sessionKey) {
const usage = runResult.meta.agentMeta?.usage;
const modelUsed = runResult.meta.agentMeta?.model ?? defaultModel;
const contextTokensUsed =
agentCfgContextTokens ??
lookupContextTokens(modelUsed) ??
sessionEntry?.contextTokens ??
DEFAULT_CONTEXT_TOKENS;
if (usage) {
const entry = sessionEntry ?? sessionStore[sessionKey];
if (entry) {
const input = usage.input ?? 0;
const output = usage.output ?? 0;
const promptTokens =
input + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
const nextEntry = {
...entry,
inputTokens: input,
outputTokens: output,
totalTokens:
promptTokens > 0 ? promptTokens : (usage.total ?? input),
model: modelUsed,
contextTokens: contextTokensUsed ?? entry.contextTokens,
updatedAt: Date.now(),
};
sessionStore[sessionKey] = nextEntry;
if (storePath) {
await saveSessionStore(storePath, sessionStore);
}
}
} else if (modelUsed || contextTokensUsed) {
const entry = sessionEntry ?? sessionStore[sessionKey];
if (entry) {
sessionStore[sessionKey] = {
...entry,
model: modelUsed ?? entry.model,
contextTokens: contextTokensUsed ?? entry.contextTokens,
};
if (storePath) {
await saveSessionStore(storePath, sessionStore);
}
}
}
}
// If verbose is enabled and this is a new session, prepend a session hint.
let finalPayloads = filteredPayloads;
if (resolvedVerboseLevel === "on" && isNewSession) {
finalPayloads = [
{ text: `🧭 New session: ${followupRun.run.sessionId}` },
...finalPayloads,
];
}
return finalizeWithFollowup(
finalPayloads.length === 1 ? finalPayloads[0] : finalPayloads,
);
} finally {
typing.cleanup();
}
}
+51
View File
@@ -0,0 +1,51 @@
import type { ClawdisConfig } from "../../config/config.js";
import { resolveTextChunkLimit, type TextChunkSurface } from "../chunk.js";
const DEFAULT_BLOCK_STREAM_MIN = 800;
const DEFAULT_BLOCK_STREAM_MAX = 1200;
const BLOCK_CHUNK_SURFACES = new Set<TextChunkSurface>([
"whatsapp",
"telegram",
"discord",
"signal",
"imessage",
"webchat",
]);
function normalizeChunkSurface(surface?: string): TextChunkSurface | undefined {
if (!surface) return undefined;
const cleaned = surface.trim().toLowerCase();
return BLOCK_CHUNK_SURFACES.has(cleaned as TextChunkSurface)
? (cleaned as TextChunkSurface)
: undefined;
}
export function resolveBlockStreamingChunking(
cfg: ClawdisConfig | undefined,
surface?: string,
): {
minChars: number;
maxChars: number;
breakPreference: "paragraph" | "newline" | "sentence";
} {
const surfaceKey = normalizeChunkSurface(surface);
const textLimit = resolveTextChunkLimit(cfg, surfaceKey);
const chunkCfg = cfg?.agent?.blockStreamingChunk;
const maxRequested = Math.max(
1,
Math.floor(chunkCfg?.maxChars ?? DEFAULT_BLOCK_STREAM_MAX),
);
const maxChars = Math.max(1, Math.min(maxRequested, textLimit));
const minRequested = Math.max(
1,
Math.floor(chunkCfg?.minChars ?? DEFAULT_BLOCK_STREAM_MIN),
);
const minChars = Math.min(minRequested, maxChars);
const breakPreference =
chunkCfg?.breakPreference === "newline" ||
chunkCfg?.breakPreference === "sentence"
? chunkCfg.breakPreference
: "paragraph";
return { minChars, maxChars, breakPreference };
}
+41
View File
@@ -0,0 +1,41 @@
import type { SessionEntry } from "../../config/sessions.js";
import { saveSessionStore } from "../../config/sessions.js";
import { setAbortMemory } from "./abort.js";
export async function applySessionHints(params: {
baseBody: string;
abortedLastRun: boolean;
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionKey?: string;
storePath?: string;
abortKey?: string;
messageId?: string;
}): Promise<string> {
let prefixedBodyBase = params.baseBody;
const abortedHint = params.abortedLastRun
? "Note: The previous agent run was aborted by the user. Resume carefully or ask for clarification."
: "";
if (abortedHint) {
prefixedBodyBase = `${abortedHint}\n\n${prefixedBodyBase}`;
if (params.sessionEntry && params.sessionStore && params.sessionKey) {
params.sessionEntry.abortedLastRun = false;
params.sessionEntry.updatedAt = Date.now();
params.sessionStore[params.sessionKey] = params.sessionEntry;
if (params.storePath) {
await saveSessionStore(params.storePath, params.sessionStore);
}
} else if (params.abortKey) {
setAbortMemory(params.abortKey, false);
}
}
const messageIdHint = params.messageId?.trim()
? `[message_id: ${params.messageId.trim()}]`
: "";
if (messageIdHint) {
prefixedBodyBase = `${prefixedBodyBase}\n${messageIdHint}`;
}
return prefixedBodyBase;
}
+307
View File
@@ -0,0 +1,307 @@
import type { ClawdisConfig } from "../../config/config.js";
import {
type SessionEntry,
type SessionScope,
saveSessionStore,
} from "../../config/sessions.js";
import { logVerbose } from "../../globals.js";
import { triggerClawdisRestart } from "../../infra/restart.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js";
import { normalizeE164 } from "../../utils.js";
import { resolveHeartbeatSeconds } from "../../web/reconnect.js";
import { getWebAuthAgeMs, webAuthExists } from "../../web/session.js";
import {
normalizeGroupActivation,
parseActivationCommand,
} from "../group-activation.js";
import { parseSendPolicyCommand } from "../send-policy.js";
import { buildStatusMessage } from "../status.js";
import type { MsgContext } from "../templating.js";
import type { ThinkLevel, VerboseLevel } from "../thinking.js";
import type { ReplyPayload } from "../types.js";
import { isAbortTrigger, setAbortMemory } from "./abort.js";
import { stripMentions } from "./mentions.js";
export type CommandContext = {
surface: string;
isWhatsAppSurface: boolean;
ownerList: string[];
isOwnerSender: boolean;
senderE164?: string;
abortKey?: string;
rawBodyNormalized: string;
commandBodyNormalized: string;
from?: string;
to?: string;
};
export function buildCommandContext(params: {
ctx: MsgContext;
cfg: ClawdisConfig;
sessionKey?: string;
isGroup: boolean;
triggerBodyNormalized: string;
}): CommandContext {
const { ctx, cfg, sessionKey, isGroup, triggerBodyNormalized } = params;
const surface = (ctx.Surface ?? "").trim().toLowerCase();
const isWhatsAppSurface =
surface === "whatsapp" ||
(ctx.From ?? "").startsWith("whatsapp:") ||
(ctx.To ?? "").startsWith("whatsapp:");
const configuredAllowFrom = isWhatsAppSurface
? cfg.whatsapp?.allowFrom
: undefined;
const from = (ctx.From ?? "").replace(/^whatsapp:/, "");
const to = (ctx.To ?? "").replace(/^whatsapp:/, "");
const defaultAllowFrom =
isWhatsAppSurface &&
(!configuredAllowFrom || configuredAllowFrom.length === 0) &&
to
? [to]
: undefined;
const allowFrom =
configuredAllowFrom && configuredAllowFrom.length > 0
? configuredAllowFrom
: defaultAllowFrom;
const abortKey = sessionKey ?? (from || undefined) ?? (to || undefined);
const rawBodyNormalized = triggerBodyNormalized;
const commandBodyNormalized = isGroup
? stripMentions(rawBodyNormalized, ctx, cfg)
: rawBodyNormalized;
const senderE164 = normalizeE164(ctx.SenderE164 ?? "");
const ownerCandidates = isWhatsAppSurface
? (allowFrom ?? []).filter((entry) => entry && entry !== "*")
: [];
if (isWhatsAppSurface && ownerCandidates.length === 0 && to) {
ownerCandidates.push(to);
}
const ownerList = ownerCandidates
.map((entry) => normalizeE164(entry))
.filter((entry): entry is string => Boolean(entry));
const isOwnerSender =
Boolean(senderE164) && ownerList.includes(senderE164 ?? "");
return {
surface,
isWhatsAppSurface,
ownerList,
isOwnerSender,
senderE164: senderE164 || undefined,
abortKey,
rawBodyNormalized,
commandBodyNormalized,
from: from || undefined,
to: to || undefined,
};
}
export async function handleCommands(params: {
ctx: MsgContext;
cfg: ClawdisConfig;
command: CommandContext;
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionKey?: string;
storePath?: string;
sessionScope?: SessionScope;
workspaceDir: string;
defaultGroupActivation: () => "always" | "mention";
resolvedThinkLevel?: ThinkLevel;
resolvedVerboseLevel: VerboseLevel;
resolveDefaultThinkingLevel: () => Promise<ThinkLevel | undefined>;
provider: string;
model: string;
contextTokens: number;
isGroup: boolean;
}): Promise<{
reply?: ReplyPayload;
shouldContinue: boolean;
}> {
const {
cfg,
command,
sessionEntry,
sessionStore,
sessionKey,
storePath,
sessionScope,
workspaceDir,
defaultGroupActivation,
resolvedThinkLevel,
resolvedVerboseLevel,
resolveDefaultThinkingLevel,
model,
contextTokens,
isGroup,
} = params;
const activationCommand = parseActivationCommand(
command.commandBodyNormalized,
);
const sendPolicyCommand = parseSendPolicyCommand(
command.commandBodyNormalized,
);
if (activationCommand.hasCommand) {
if (!isGroup) {
return {
shouldContinue: false,
reply: { text: "⚙️ Group activation only applies to group chats." },
};
}
if (!command.isOwnerSender) {
logVerbose(
`Ignoring /activation from non-owner in group: ${command.senderE164 || "<unknown>"}`,
);
return { shouldContinue: false };
}
if (!activationCommand.mode) {
return {
shouldContinue: false,
reply: { text: "⚙️ Usage: /activation mention|always" },
};
}
if (sessionEntry && sessionStore && sessionKey) {
sessionEntry.groupActivation = activationCommand.mode;
sessionEntry.groupActivationNeedsSystemIntro = true;
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await saveSessionStore(storePath, sessionStore);
}
}
return {
shouldContinue: false,
reply: { text: `⚙️ Group activation set to ${activationCommand.mode}.` },
};
}
if (sendPolicyCommand.hasCommand) {
if (!command.isOwnerSender) {
logVerbose(
`Ignoring /send from non-owner: ${command.senderE164 || "<unknown>"}`,
);
return { shouldContinue: false };
}
if (!sendPolicyCommand.mode) {
return {
shouldContinue: false,
reply: { text: "⚙️ Usage: /send on|off|inherit" },
};
}
if (sessionEntry && sessionStore && sessionKey) {
if (sendPolicyCommand.mode === "inherit") {
delete sessionEntry.sendPolicy;
} else {
sessionEntry.sendPolicy = sendPolicyCommand.mode;
}
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await saveSessionStore(storePath, sessionStore);
}
}
const label =
sendPolicyCommand.mode === "inherit"
? "inherit"
: sendPolicyCommand.mode === "allow"
? "on"
: "off";
return {
shouldContinue: false,
reply: { text: `⚙️ Send policy set to ${label}.` },
};
}
if (
command.commandBodyNormalized === "/restart" ||
command.commandBodyNormalized === "restart" ||
command.commandBodyNormalized.startsWith("/restart ")
) {
if (isGroup && !command.isOwnerSender) {
logVerbose(
`Ignoring /restart from non-owner in group: ${command.senderE164 || "<unknown>"}`,
);
return { shouldContinue: false };
}
const restartMethod = triggerClawdisRestart();
return {
shouldContinue: false,
reply: {
text: `⚙️ Restarting clawdis via ${restartMethod}; give me a few seconds to come back online.`,
},
};
}
if (
command.commandBodyNormalized === "/status" ||
command.commandBodyNormalized === "status" ||
command.commandBodyNormalized.startsWith("/status ")
) {
if (isGroup && !command.isOwnerSender) {
logVerbose(
`Ignoring /status from non-owner in group: ${command.senderE164 || "<unknown>"}`,
);
return { shouldContinue: false };
}
const webLinked = await webAuthExists();
const webAuthAgeMs = getWebAuthAgeMs();
const heartbeatSeconds = resolveHeartbeatSeconds(cfg, undefined);
const groupActivation = isGroup
? (normalizeGroupActivation(sessionEntry?.groupActivation) ??
defaultGroupActivation())
: undefined;
const statusText = buildStatusMessage({
agent: {
model,
contextTokens,
thinkingDefault: cfg.agent?.thinkingDefault,
verboseDefault: cfg.agent?.verboseDefault,
},
workspaceDir,
sessionEntry,
sessionKey,
sessionScope,
storePath,
groupActivation,
resolvedThink:
resolvedThinkLevel ?? (await resolveDefaultThinkingLevel()),
resolvedVerbose: resolvedVerboseLevel,
webLinked,
webAuthAgeMs,
heartbeatSeconds,
});
return { shouldContinue: false, reply: { text: statusText } };
}
const abortRequested = isAbortTrigger(command.rawBodyNormalized);
if (abortRequested) {
if (sessionEntry && sessionStore && sessionKey) {
sessionEntry.abortedLastRun = true;
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await saveSessionStore(storePath, sessionStore);
}
} else if (command.abortKey) {
setAbortMemory(command.abortKey, true);
}
return { shouldContinue: false, reply: { text: "⚙️ Agent was aborted." } };
}
const sendPolicy = resolveSendPolicy({
cfg,
entry: sessionEntry,
sessionKey,
surface: sessionEntry?.surface ?? command.surface,
chatType: sessionEntry?.chatType,
});
if (sendPolicy === "deny") {
logVerbose(`Send blocked by policy for session ${sessionKey ?? "unknown"}`);
return { shouldContinue: false };
}
return { shouldContinue: true };
}
+571
View File
@@ -0,0 +1,571 @@
import { lookupContextTokens } from "../../agents/context.js";
import {
DEFAULT_CONTEXT_TOKENS,
DEFAULT_MODEL,
DEFAULT_PROVIDER,
} from "../../agents/defaults.js";
import {
buildModelAliasIndex,
type ModelAliasIndex,
modelKey,
resolveConfiguredModelRef,
resolveModelRefFromString,
} from "../../agents/model-selection.js";
import type { ClawdisConfig } from "../../config/config.js";
import { type SessionEntry, saveSessionStore } from "../../config/sessions.js";
import { enqueueSystemEvent } from "../../infra/system-events.js";
import { extractModelDirective } from "../model.js";
import type { MsgContext } from "../templating.js";
import type { ReplyPayload } from "../types.js";
import {
type ElevatedLevel,
extractElevatedDirective,
extractThinkDirective,
extractVerboseDirective,
type ThinkLevel,
type VerboseLevel,
} from "./directives.js";
import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
import {
type ModelDirectiveSelection,
resolveModelDirectiveSelection,
} from "./model-selection.js";
import {
extractQueueDirective,
type QueueDropPolicy,
type QueueMode,
} from "./queue.js";
const SYSTEM_MARK = "⚙️";
export type InlineDirectives = {
cleaned: string;
hasThinkDirective: boolean;
thinkLevel?: ThinkLevel;
rawThinkLevel?: string;
hasVerboseDirective: boolean;
verboseLevel?: VerboseLevel;
rawVerboseLevel?: string;
hasElevatedDirective: boolean;
elevatedLevel?: ElevatedLevel;
rawElevatedLevel?: string;
hasModelDirective: boolean;
rawModelDirective?: string;
hasQueueDirective: boolean;
queueMode?: QueueMode;
queueReset: boolean;
rawQueueMode?: string;
debounceMs?: number;
cap?: number;
dropPolicy?: QueueDropPolicy;
rawDebounce?: string;
rawCap?: string;
rawDrop?: string;
hasQueueOptions: boolean;
};
export function parseInlineDirectives(body: string): InlineDirectives {
const {
cleaned: thinkCleaned,
thinkLevel,
rawLevel: rawThinkLevel,
hasDirective: hasThinkDirective,
} = extractThinkDirective(body);
const {
cleaned: verboseCleaned,
verboseLevel,
rawLevel: rawVerboseLevel,
hasDirective: hasVerboseDirective,
} = extractVerboseDirective(thinkCleaned);
const {
cleaned: elevatedCleaned,
elevatedLevel,
rawLevel: rawElevatedLevel,
hasDirective: hasElevatedDirective,
} = extractElevatedDirective(verboseCleaned);
const {
cleaned: modelCleaned,
rawModel,
hasDirective: hasModelDirective,
} = extractModelDirective(elevatedCleaned);
const {
cleaned: queueCleaned,
queueMode,
queueReset,
rawMode,
debounceMs,
cap,
dropPolicy,
rawDebounce,
rawCap,
rawDrop,
hasDirective: hasQueueDirective,
hasOptions: hasQueueOptions,
} = extractQueueDirective(modelCleaned);
return {
cleaned: queueCleaned,
hasThinkDirective,
thinkLevel,
rawThinkLevel,
hasVerboseDirective,
verboseLevel,
rawVerboseLevel,
hasElevatedDirective,
elevatedLevel,
rawElevatedLevel,
hasModelDirective,
rawModelDirective: rawModel,
hasQueueDirective,
queueMode,
queueReset,
rawQueueMode: rawMode,
debounceMs,
cap,
dropPolicy,
rawDebounce,
rawCap,
rawDrop,
hasQueueOptions,
};
}
export function isDirectiveOnly(params: {
directives: InlineDirectives;
cleanedBody: string;
ctx: MsgContext;
cfg: ClawdisConfig;
isGroup: boolean;
}): boolean {
const { directives, cleanedBody, ctx, cfg, isGroup } = params;
if (
!directives.hasThinkDirective &&
!directives.hasVerboseDirective &&
!directives.hasElevatedDirective &&
!directives.hasModelDirective &&
!directives.hasQueueDirective
)
return false;
const stripped = stripStructuralPrefixes(cleanedBody ?? "");
const noMentions = isGroup ? stripMentions(stripped, ctx, cfg) : stripped;
return noMentions.length === 0;
}
export async function handleDirectiveOnly(params: {
directives: InlineDirectives;
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionKey?: string;
storePath?: string;
elevatedEnabled: boolean;
elevatedAllowed: boolean;
defaultProvider: string;
defaultModel: string;
aliasIndex: ModelAliasIndex;
allowedModelKeys: Set<string>;
allowedModelCatalog: Awaited<
ReturnType<typeof import("../../agents/model-catalog.js").loadModelCatalog>
>;
resetModelOverride: boolean;
provider: string;
model: string;
initialModelLabel: string;
formatModelSwitchEvent: (label: string, alias?: string) => string;
}): Promise<ReplyPayload | undefined> {
const {
directives,
sessionEntry,
sessionStore,
sessionKey,
storePath,
elevatedEnabled,
elevatedAllowed,
defaultProvider,
defaultModel,
aliasIndex,
allowedModelKeys,
allowedModelCatalog,
resetModelOverride,
initialModelLabel,
formatModelSwitchEvent,
} = params;
if (directives.hasModelDirective) {
const isModelListAlias =
directives.rawModelDirective?.trim().toLowerCase() === "status";
if (!directives.rawModelDirective || isModelListAlias) {
if (allowedModelCatalog.length === 0) {
return { text: "No models available." };
}
const current = `${params.provider}/${params.model}`;
const defaultLabel = `${defaultProvider}/${defaultModel}`;
const header =
current === defaultLabel
? `Models (current: ${current}):`
: `Models (current: ${current}, default: ${defaultLabel}):`;
const lines = [header];
if (resetModelOverride) {
lines.push(`(previous selection reset to default)`);
}
for (const entry of allowedModelCatalog) {
const label = `${entry.provider}/${entry.id}`;
const aliases = aliasIndex.byKey.get(label);
const aliasSuffix =
aliases && aliases.length > 0
? ` (alias: ${aliases.join(", ")})`
: "";
const suffix =
entry.name && entry.name !== entry.id ? ` — ${entry.name}` : "";
lines.push(`- ${label}${aliasSuffix}${suffix}`);
}
return { text: lines.join("\n") };
}
}
if (directives.hasThinkDirective && !directives.thinkLevel) {
return {
text: `Unrecognized thinking level "${directives.rawThinkLevel ?? ""}". Valid levels: off, minimal, low, medium, high.`,
};
}
if (directives.hasVerboseDirective && !directives.verboseLevel) {
return {
text: `Unrecognized verbose level "${directives.rawVerboseLevel ?? ""}". Valid levels: off, on.`,
};
}
if (directives.hasElevatedDirective && !directives.elevatedLevel) {
return {
text: `Unrecognized elevated level "${directives.rawElevatedLevel ?? ""}". Valid levels: off, on.`,
};
}
if (
directives.hasElevatedDirective &&
(!elevatedEnabled || !elevatedAllowed)
) {
return { text: "elevated is not available right now." };
}
const queueModeInvalid =
directives.hasQueueDirective &&
!directives.queueMode &&
!directives.queueReset &&
Boolean(directives.rawQueueMode);
const queueDebounceInvalid =
directives.hasQueueDirective &&
directives.rawDebounce !== undefined &&
typeof directives.debounceMs !== "number";
const queueCapInvalid =
directives.hasQueueDirective &&
directives.rawCap !== undefined &&
typeof directives.cap !== "number";
const queueDropInvalid =
directives.hasQueueDirective &&
directives.rawDrop !== undefined &&
!directives.dropPolicy;
if (
queueModeInvalid ||
queueDebounceInvalid ||
queueCapInvalid ||
queueDropInvalid
) {
const errors: string[] = [];
if (queueModeInvalid) {
errors.push(
`Unrecognized queue mode "${directives.rawQueueMode ?? ""}". Valid modes: steer, followup, collect, steer+backlog, interrupt.`,
);
}
if (queueDebounceInvalid) {
errors.push(
`Invalid debounce "${directives.rawDebounce ?? ""}". Use ms/s/m (e.g. debounce:1500ms, debounce:2s).`,
);
}
if (queueCapInvalid) {
errors.push(
`Invalid cap "${directives.rawCap ?? ""}". Use a positive integer (e.g. cap:10).`,
);
}
if (queueDropInvalid) {
errors.push(
`Invalid drop policy "${directives.rawDrop ?? ""}". Use drop:old, drop:new, or drop:summarize.`,
);
}
return { text: errors.join(" ") };
}
let modelSelection: ModelDirectiveSelection | undefined;
if (directives.hasModelDirective && directives.rawModelDirective) {
const resolved = resolveModelDirectiveSelection({
raw: directives.rawModelDirective,
defaultProvider,
defaultModel,
aliasIndex,
allowedModelKeys,
});
if (resolved.error) {
return { text: resolved.error };
}
modelSelection = resolved.selection;
if (modelSelection) {
const nextLabel = `${modelSelection.provider}/${modelSelection.model}`;
if (nextLabel !== initialModelLabel) {
enqueueSystemEvent(
formatModelSwitchEvent(nextLabel, modelSelection.alias),
{
contextKey: `model:${nextLabel}`,
},
);
}
}
}
if (sessionEntry && sessionStore && sessionKey) {
if (directives.hasThinkDirective && directives.thinkLevel) {
if (directives.thinkLevel === "off") delete sessionEntry.thinkingLevel;
else sessionEntry.thinkingLevel = directives.thinkLevel;
}
if (directives.hasVerboseDirective && directives.verboseLevel) {
if (directives.verboseLevel === "off") delete sessionEntry.verboseLevel;
else sessionEntry.verboseLevel = directives.verboseLevel;
}
if (directives.hasElevatedDirective && directives.elevatedLevel) {
if (directives.elevatedLevel === "off") delete sessionEntry.elevatedLevel;
else sessionEntry.elevatedLevel = directives.elevatedLevel;
}
if (modelSelection) {
if (modelSelection.isDefault) {
delete sessionEntry.providerOverride;
delete sessionEntry.modelOverride;
} else {
sessionEntry.providerOverride = modelSelection.provider;
sessionEntry.modelOverride = modelSelection.model;
}
}
if (directives.hasQueueDirective && directives.queueReset) {
delete sessionEntry.queueMode;
delete sessionEntry.queueDebounceMs;
delete sessionEntry.queueCap;
delete sessionEntry.queueDrop;
} else if (directives.hasQueueDirective) {
if (directives.queueMode) sessionEntry.queueMode = directives.queueMode;
if (typeof directives.debounceMs === "number") {
sessionEntry.queueDebounceMs = directives.debounceMs;
}
if (typeof directives.cap === "number") {
sessionEntry.queueCap = directives.cap;
}
if (directives.dropPolicy) {
sessionEntry.queueDrop = directives.dropPolicy;
}
}
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await saveSessionStore(storePath, sessionStore);
}
}
const parts: string[] = [];
if (directives.hasThinkDirective && directives.thinkLevel) {
parts.push(
directives.thinkLevel === "off"
? "Thinking disabled."
: `Thinking level set to ${directives.thinkLevel}.`,
);
}
if (directives.hasVerboseDirective && directives.verboseLevel) {
parts.push(
directives.verboseLevel === "off"
? `${SYSTEM_MARK} Verbose logging disabled.`
: `${SYSTEM_MARK} Verbose logging enabled.`,
);
}
if (directives.hasElevatedDirective && directives.elevatedLevel) {
parts.push(
directives.elevatedLevel === "off"
? `${SYSTEM_MARK} Elevated mode disabled.`
: `${SYSTEM_MARK} Elevated mode enabled.`,
);
}
if (modelSelection) {
const label = `${modelSelection.provider}/${modelSelection.model}`;
const labelWithAlias = modelSelection.alias
? `${modelSelection.alias} (${label})`
: label;
parts.push(
modelSelection.isDefault
? `Model reset to default (${labelWithAlias}).`
: `Model set to ${labelWithAlias}.`,
);
}
if (directives.hasQueueDirective && directives.queueMode) {
parts.push(`${SYSTEM_MARK} Queue mode set to ${directives.queueMode}.`);
} else if (directives.hasQueueDirective && directives.queueReset) {
parts.push(`${SYSTEM_MARK} Queue mode reset to default.`);
}
if (
directives.hasQueueDirective &&
typeof directives.debounceMs === "number"
) {
parts.push(
`${SYSTEM_MARK} Queue debounce set to ${directives.debounceMs}ms.`,
);
}
if (directives.hasQueueDirective && typeof directives.cap === "number") {
parts.push(`${SYSTEM_MARK} Queue cap set to ${directives.cap}.`);
}
if (directives.hasQueueDirective && directives.dropPolicy) {
parts.push(`${SYSTEM_MARK} Queue drop set to ${directives.dropPolicy}.`);
}
const ack = parts.join(" ").trim();
return { text: ack || "OK." };
}
export async function persistInlineDirectives(params: {
directives: InlineDirectives;
effectiveModelDirective?: string;
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionKey?: string;
storePath?: string;
elevatedEnabled: boolean;
elevatedAllowed: boolean;
defaultProvider: string;
defaultModel: string;
aliasIndex: ModelAliasIndex;
allowedModelKeys: Set<string>;
provider: string;
model: string;
initialModelLabel: string;
formatModelSwitchEvent: (label: string, alias?: string) => string;
agentCfg: ClawdisConfig["agent"] | undefined;
}): Promise<{ provider: string; model: string; contextTokens: number }> {
const {
directives,
sessionEntry,
sessionStore,
sessionKey,
storePath,
elevatedEnabled,
elevatedAllowed,
defaultProvider,
defaultModel,
aliasIndex,
allowedModelKeys,
initialModelLabel,
formatModelSwitchEvent,
agentCfg,
} = params;
let { provider, model } = params;
if (sessionEntry && sessionStore && sessionKey) {
let updated = false;
if (directives.hasThinkDirective && directives.thinkLevel) {
if (directives.thinkLevel === "off") {
delete sessionEntry.thinkingLevel;
} else {
sessionEntry.thinkingLevel = directives.thinkLevel;
}
updated = true;
}
if (directives.hasVerboseDirective && directives.verboseLevel) {
if (directives.verboseLevel === "off") {
delete sessionEntry.verboseLevel;
} else {
sessionEntry.verboseLevel = directives.verboseLevel;
}
updated = true;
}
if (
directives.hasElevatedDirective &&
directives.elevatedLevel &&
elevatedEnabled &&
elevatedAllowed
) {
if (directives.elevatedLevel === "off") {
delete sessionEntry.elevatedLevel;
} else {
sessionEntry.elevatedLevel = directives.elevatedLevel;
}
updated = true;
}
const modelDirective =
directives.hasModelDirective && params.effectiveModelDirective
? params.effectiveModelDirective
: undefined;
if (modelDirective) {
const resolved = resolveModelRefFromString({
raw: modelDirective,
defaultProvider,
aliasIndex,
});
if (resolved) {
const key = modelKey(resolved.ref.provider, resolved.ref.model);
if (allowedModelKeys.size === 0 || allowedModelKeys.has(key)) {
const isDefault =
resolved.ref.provider === defaultProvider &&
resolved.ref.model === defaultModel;
if (isDefault) {
delete sessionEntry.providerOverride;
delete sessionEntry.modelOverride;
} else {
sessionEntry.providerOverride = resolved.ref.provider;
sessionEntry.modelOverride = resolved.ref.model;
}
provider = resolved.ref.provider;
model = resolved.ref.model;
const nextLabel = `${provider}/${model}`;
if (nextLabel !== initialModelLabel) {
enqueueSystemEvent(
formatModelSwitchEvent(nextLabel, resolved.alias),
{
contextKey: `model:${nextLabel}`,
},
);
}
updated = true;
}
}
}
if (directives.hasQueueDirective && directives.queueReset) {
delete sessionEntry.queueMode;
delete sessionEntry.queueDebounceMs;
delete sessionEntry.queueCap;
delete sessionEntry.queueDrop;
updated = true;
}
if (updated) {
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await saveSessionStore(storePath, sessionStore);
}
}
}
return {
provider,
model,
contextTokens:
agentCfg?.contextTokens ??
lookupContextTokens(model) ??
DEFAULT_CONTEXT_TOKENS,
};
}
export function resolveDefaultModel(params: { cfg: ClawdisConfig }): {
defaultProvider: string;
defaultModel: string;
aliasIndex: ModelAliasIndex;
} {
const mainModel = resolveConfiguredModelRef({
cfg: params.cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const defaultProvider = mainModel.provider;
const defaultModel = mainModel.model;
const aliasIndex = buildModelAliasIndex({
cfg: params.cfg,
defaultProvider,
});
return { defaultProvider, defaultModel, aliasIndex };
}
+77
View File
@@ -0,0 +1,77 @@
import {
type ElevatedLevel,
normalizeElevatedLevel,
normalizeThinkLevel,
normalizeVerboseLevel,
type ThinkLevel,
type VerboseLevel,
} from "../thinking.js";
export function extractThinkDirective(body?: string): {
cleaned: string;
thinkLevel?: ThinkLevel;
rawLevel?: string;
hasDirective: boolean;
} {
if (!body) return { cleaned: "", hasDirective: false };
// Match the longest keyword first to avoid partial captures (e.g. "/think:high")
const match = body.match(
/(?:^|\s)\/(?:thinking|think|t)\s*:?\s*([a-zA-Z-]+)\b/i,
);
const thinkLevel = normalizeThinkLevel(match?.[1]);
const cleaned = match
? body.replace(match[0], "").replace(/\s+/g, " ").trim()
: body.trim();
return {
cleaned,
thinkLevel,
rawLevel: match?.[1],
hasDirective: !!match,
};
}
export function extractVerboseDirective(body?: string): {
cleaned: string;
verboseLevel?: VerboseLevel;
rawLevel?: string;
hasDirective: boolean;
} {
if (!body) return { cleaned: "", hasDirective: false };
const match = body.match(
/(?:^|\s)\/(?:verbose|v)(?=$|\s|:)\s*:?\s*([a-zA-Z-]+)\b/i,
);
const verboseLevel = normalizeVerboseLevel(match?.[1]);
const cleaned = match
? body.replace(match[0], "").replace(/\s+/g, " ").trim()
: body.trim();
return {
cleaned,
verboseLevel,
rawLevel: match?.[1],
hasDirective: !!match,
};
}
export function extractElevatedDirective(body?: string): {
cleaned: string;
elevatedLevel?: ElevatedLevel;
rawLevel?: string;
hasDirective: boolean;
} {
if (!body) return { cleaned: "", hasDirective: false };
const match = body.match(
/(?:^|\s)\/(?:elevated|elev)(?=$|\s|:)\s*:?\s*([a-zA-Z-]+)\b/i,
);
const elevatedLevel = normalizeElevatedLevel(match?.[1]);
const cleaned = match
? body.replace(match[0], "").replace(/\s+/g, " ").trim()
: body.trim();
return {
cleaned,
elevatedLevel,
rawLevel: match?.[1],
hasDirective: !!match,
};
}
export type { ElevatedLevel, ThinkLevel, VerboseLevel };
+169
View File
@@ -0,0 +1,169 @@
import crypto from "node:crypto";
import { lookupContextTokens } from "../../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js";
import { type SessionEntry, saveSessionStore } from "../../config/sessions.js";
import { logVerbose } from "../../globals.js";
import { registerAgentRunContext } from "../../infra/agent-events.js";
import { defaultRuntime } from "../../runtime.js";
import { stripHeartbeatToken } from "../heartbeat.js";
import { SILENT_REPLY_TOKEN } from "../tokens.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js";
import type { FollowupRun } from "./queue.js";
import { extractReplyToTag } from "./reply-tags.js";
import type { TypingController } from "./typing.js";
export function createFollowupRunner(params: {
opts?: GetReplyOptions;
typing: TypingController;
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionKey?: string;
storePath?: string;
defaultModel: string;
agentCfgContextTokens?: number;
}): (queued: FollowupRun) => Promise<void> {
const {
opts,
typing,
sessionEntry,
sessionStore,
sessionKey,
storePath,
defaultModel,
agentCfgContextTokens,
} = params;
const sendFollowupPayloads = async (payloads: ReplyPayload[]) => {
if (!opts?.onBlockReply) {
logVerbose("followup queue: no onBlockReply handler; dropping payloads");
return;
}
for (const payload of payloads) {
if (!payload?.text && !payload?.mediaUrl && !payload?.mediaUrls?.length) {
continue;
}
if (
payload.text?.trim() === SILENT_REPLY_TOKEN &&
!payload.mediaUrl &&
!payload.mediaUrls?.length
) {
continue;
}
await typing.startTypingOnText(payload.text);
await opts.onBlockReply(payload);
}
};
return async (queued: FollowupRun) => {
const runId = crypto.randomUUID();
if (queued.run.sessionKey) {
registerAgentRunContext(runId, { sessionKey: queued.run.sessionKey });
}
let runResult: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
try {
runResult = await runEmbeddedPiAgent({
sessionId: queued.run.sessionId,
sessionKey: queued.run.sessionKey,
surface: queued.run.surface,
sessionFile: queued.run.sessionFile,
workspaceDir: queued.run.workspaceDir,
config: queued.run.config,
skillsSnapshot: queued.run.skillsSnapshot,
prompt: queued.prompt,
extraSystemPrompt: queued.run.extraSystemPrompt,
ownerNumbers: queued.run.ownerNumbers,
enforceFinalTag: queued.run.enforceFinalTag,
provider: queued.run.provider,
model: queued.run.model,
thinkLevel: queued.run.thinkLevel,
verboseLevel: queued.run.verboseLevel,
bashElevated: queued.run.bashElevated,
timeoutMs: queued.run.timeoutMs,
runId,
blockReplyBreak: queued.run.blockReplyBreak,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
defaultRuntime.error?.(`Followup agent failed before reply: ${message}`);
return;
}
const payloadArray = runResult.payloads ?? [];
if (payloadArray.length === 0) return;
const sanitizedPayloads = payloadArray.flatMap((payload) => {
const text = payload.text;
if (!text || !text.includes("HEARTBEAT_OK")) return [payload];
const stripped = stripHeartbeatToken(text, { mode: "message" });
const hasMedia =
Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
if (stripped.shouldSkip && !hasMedia) return [];
return [{ ...payload, text: stripped.text }];
});
const replyTaggedPayloads: ReplyPayload[] = sanitizedPayloads
.map((payload) => {
const { cleaned, replyToId } = extractReplyToTag(payload.text);
return {
...payload,
text: cleaned ? cleaned : undefined,
replyToId: replyToId ?? payload.replyToId,
};
})
.filter(
(payload) =>
payload.text ||
payload.mediaUrl ||
(payload.mediaUrls && payload.mediaUrls.length > 0),
);
if (replyTaggedPayloads.length === 0) return;
if (sessionStore && sessionKey) {
const usage = runResult.meta.agentMeta?.usage;
const modelUsed = runResult.meta.agentMeta?.model ?? defaultModel;
const contextTokensUsed =
agentCfgContextTokens ??
lookupContextTokens(modelUsed) ??
sessionEntry?.contextTokens ??
DEFAULT_CONTEXT_TOKENS;
if (usage) {
const entry = sessionStore[sessionKey];
if (entry) {
const input = usage.input ?? 0;
const output = usage.output ?? 0;
const promptTokens =
input + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
sessionStore[sessionKey] = {
...entry,
inputTokens: input,
outputTokens: output,
totalTokens:
promptTokens > 0 ? promptTokens : (usage.total ?? input),
model: modelUsed,
contextTokens: contextTokensUsed ?? entry.contextTokens,
updatedAt: Date.now(),
};
if (storePath) {
await saveSessionStore(storePath, sessionStore);
}
}
} else if (modelUsed || contextTokensUsed) {
const entry = sessionStore[sessionKey];
if (entry) {
sessionStore[sessionKey] = {
...entry,
model: modelUsed ?? entry.model,
contextTokens: contextTokensUsed ?? entry.contextTokens,
};
if (storePath) {
await saveSessionStore(storePath, sessionStore);
}
}
}
}
await sendFollowupPayloads(replyTaggedPayloads);
};
}
+108
View File
@@ -0,0 +1,108 @@
import type { ClawdisConfig } from "../../config/config.js";
import type {
GroupKeyResolution,
SessionEntry,
} from "../../config/sessions.js";
import { normalizeGroupActivation } from "../group-activation.js";
import type { TemplateContext } from "../templating.js";
export function resolveGroupRequireMention(params: {
cfg: ClawdisConfig;
ctx: TemplateContext;
groupResolution?: GroupKeyResolution;
}): boolean {
const { cfg, ctx, groupResolution } = params;
const surface = groupResolution?.surface ?? ctx.Surface?.trim().toLowerCase();
const groupId = groupResolution?.id ?? ctx.From?.replace(/^group:/, "");
if (surface === "telegram") {
if (groupId) {
const groupConfig = cfg.telegram?.groups?.[groupId];
if (typeof groupConfig?.requireMention === "boolean") {
return groupConfig.requireMention;
}
}
const groupDefault = cfg.telegram?.groups?.["*"]?.requireMention;
if (typeof groupDefault === "boolean") return groupDefault;
return true;
}
if (surface === "whatsapp") {
if (groupId) {
const groupConfig = cfg.whatsapp?.groups?.[groupId];
if (typeof groupConfig?.requireMention === "boolean") {
return groupConfig.requireMention;
}
}
const groupDefault = cfg.whatsapp?.groups?.["*"]?.requireMention;
if (typeof groupDefault === "boolean") return groupDefault;
return true;
}
if (surface === "imessage") {
if (groupId) {
const groupConfig = cfg.imessage?.groups?.[groupId];
if (typeof groupConfig?.requireMention === "boolean") {
return groupConfig.requireMention;
}
}
const groupDefault = cfg.imessage?.groups?.["*"]?.requireMention;
if (typeof groupDefault === "boolean") return groupDefault;
return true;
}
return true;
}
export function defaultGroupActivation(
requireMention: boolean,
): "always" | "mention" {
return requireMention === false ? "always" : "mention";
}
export function buildGroupIntro(params: {
sessionCtx: TemplateContext;
sessionEntry?: SessionEntry;
defaultActivation: "always" | "mention";
silentToken: string;
}): string {
const activation =
normalizeGroupActivation(params.sessionEntry?.groupActivation) ??
params.defaultActivation;
const subject = params.sessionCtx.GroupSubject?.trim();
const members = params.sessionCtx.GroupMembers?.trim();
const surface = params.sessionCtx.Surface?.trim().toLowerCase();
const surfaceLabel = (() => {
if (!surface) return "chat";
if (surface === "whatsapp") return "WhatsApp";
if (surface === "telegram") return "Telegram";
if (surface === "discord") return "Discord";
if (surface === "webchat") return "WebChat";
return `${surface.at(0)?.toUpperCase() ?? ""}${surface.slice(1)}`;
})();
const subjectLine = subject
? `You are replying inside the ${surfaceLabel} group "${subject}".`
: `You are replying inside a ${surfaceLabel} group chat.`;
const membersLine = members ? `Group members: ${members}.` : undefined;
const activationLine =
activation === "always"
? "Activation: always-on (you receive every group message)."
: "Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included).";
const silenceLine =
activation === "always"
? `If no response is needed, reply with exactly "${params.silentToken}" (no other text) so Clawdis stays silent.`
: undefined;
const cautionLine =
activation === "always"
? "Be extremely selective: reply only when you are directly addressed, asked a question, or can add clear value. Otherwise stay silent."
: undefined;
const lurkLine =
"Be a good group participant: lurk and follow the conversation, but only chime in when you have something genuinely helpful or relevant to add. Don't feel obligated to respond to every message — quality over quantity. Even when lurking silently, you can use emoji reactions to acknowledge messages, show support, or react to humor — reactions are always appreciated and don't clutter the chat.";
return [
subjectLine,
membersLine,
activationLine,
silenceLine,
cautionLine,
lurkLine,
]
.filter(Boolean)
.join(" ")
.concat(" Address the specific sender noted in the message context.");
}
+45
View File
@@ -0,0 +1,45 @@
import type { ClawdisConfig } from "../../config/config.js";
import type { MsgContext } from "../templating.js";
export function stripStructuralPrefixes(text: string): string {
// Ignore wrapper labels, timestamps, and sender prefixes so directive-only
// detection still works in group batches that include history/context.
const marker = "[Current message - respond to this]";
const afterMarker = text.includes(marker)
? text.slice(text.indexOf(marker) + marker.length)
: text;
return afterMarker
.replace(/\[[^\]]+\]\s*/g, "")
.replace(/^[ \t]*[A-Za-z0-9+()\-_. ]+:\s*/gm, "")
.replace(/\s+/g, " ")
.trim();
}
export function stripMentions(
text: string,
ctx: MsgContext,
cfg: ClawdisConfig | undefined,
): string {
let result = text;
const patterns = cfg?.routing?.groupChat?.mentionPatterns ?? [];
for (const p of patterns) {
try {
const re = new RegExp(p, "gi");
result = result.replace(re, " ");
} catch {
// ignore invalid regex
}
}
const selfE164 = (ctx.To ?? "").replace(/^whatsapp:/, "");
if (selfE164) {
const esc = selfE164.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
result = result
.replace(new RegExp(esc, "gi"), " ")
.replace(new RegExp(`@${esc}`, "gi"), " ");
}
// Generic mention patterns like @123456789 or plain digits
result = result.replace(/@[0-9+]{5,}/g, " ");
// Discord-style mentions (<@123> or <@!123>)
result = result.replace(/<@!?\d+>/g, " ");
return result.replace(/\s+/g, " ").trim();
}
+188
View File
@@ -0,0 +1,188 @@
import { lookupContextTokens } from "../../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
import { loadModelCatalog } from "../../agents/model-catalog.js";
import {
buildAllowedModelSet,
type ModelAliasIndex,
modelKey,
resolveModelRefFromString,
resolveThinkingDefault,
} from "../../agents/model-selection.js";
import type { ClawdisConfig } from "../../config/config.js";
import { type SessionEntry, saveSessionStore } from "../../config/sessions.js";
import type { ThinkLevel } from "./directives.js";
export type ModelDirectiveSelection = {
provider: string;
model: string;
isDefault: boolean;
alias?: string;
};
type ModelCatalog = Awaited<ReturnType<typeof loadModelCatalog>>;
type ModelSelectionState = {
provider: string;
model: string;
allowedModelKeys: Set<string>;
allowedModelCatalog: ModelCatalog;
resetModelOverride: boolean;
resolveDefaultThinkingLevel: () => Promise<ThinkLevel | undefined>;
needsModelCatalog: boolean;
};
export async function createModelSelectionState(params: {
cfg: ClawdisConfig;
agentCfg: ClawdisConfig["agent"] | undefined;
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionKey?: string;
storePath?: string;
defaultProvider: string;
defaultModel: string;
provider: string;
model: string;
hasModelDirective: boolean;
}): Promise<ModelSelectionState> {
const {
cfg,
agentCfg,
sessionEntry,
sessionStore,
sessionKey,
storePath,
defaultProvider,
} = params;
let provider = params.provider;
let model = params.model;
const hasAllowlist = (agentCfg?.allowedModels?.length ?? 0) > 0;
const hasStoredOverride = Boolean(
sessionEntry?.modelOverride || sessionEntry?.providerOverride,
);
const needsModelCatalog =
params.hasModelDirective || hasAllowlist || hasStoredOverride;
let allowedModelKeys = new Set<string>();
let allowedModelCatalog: ModelCatalog = [];
let modelCatalog: ModelCatalog | null = null;
let resetModelOverride = false;
if (needsModelCatalog) {
modelCatalog = await loadModelCatalog({ config: cfg });
const allowed = buildAllowedModelSet({
cfg,
catalog: modelCatalog,
defaultProvider,
});
allowedModelCatalog = allowed.allowedCatalog;
allowedModelKeys = allowed.allowedKeys;
}
if (sessionEntry && sessionStore && sessionKey && hasStoredOverride) {
const overrideProvider =
sessionEntry.providerOverride?.trim() || defaultProvider;
const overrideModel = sessionEntry.modelOverride?.trim();
if (overrideModel) {
const key = modelKey(overrideProvider, overrideModel);
if (allowedModelKeys.size > 0 && !allowedModelKeys.has(key)) {
delete sessionEntry.providerOverride;
delete sessionEntry.modelOverride;
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await saveSessionStore(storePath, sessionStore);
}
resetModelOverride = true;
}
}
}
const storedProviderOverride = sessionEntry?.providerOverride?.trim();
const storedModelOverride = sessionEntry?.modelOverride?.trim();
if (storedModelOverride) {
const candidateProvider = storedProviderOverride || defaultProvider;
const key = modelKey(candidateProvider, storedModelOverride);
if (allowedModelKeys.size === 0 || allowedModelKeys.has(key)) {
provider = candidateProvider;
model = storedModelOverride;
}
}
let defaultThinkingLevel: ThinkLevel | undefined;
const resolveDefaultThinkingLevel = async () => {
if (defaultThinkingLevel) return defaultThinkingLevel;
let catalogForThinking = modelCatalog ?? allowedModelCatalog;
if (!catalogForThinking || catalogForThinking.length === 0) {
modelCatalog = await loadModelCatalog({ config: cfg });
catalogForThinking = modelCatalog;
}
defaultThinkingLevel = resolveThinkingDefault({
cfg,
provider,
model,
catalog: catalogForThinking,
});
return defaultThinkingLevel;
};
return {
provider,
model,
allowedModelKeys,
allowedModelCatalog,
resetModelOverride,
resolveDefaultThinkingLevel,
needsModelCatalog,
};
}
export function resolveModelDirectiveSelection(params: {
raw: string;
defaultProvider: string;
defaultModel: string;
aliasIndex: ModelAliasIndex;
allowedModelKeys: Set<string>;
}): { selection?: ModelDirectiveSelection; error?: string } {
const { raw, defaultProvider, defaultModel, aliasIndex, allowedModelKeys } =
params;
const resolved = resolveModelRefFromString({
raw,
defaultProvider,
aliasIndex,
});
if (!resolved) {
return {
error: `Unrecognized model "${raw}". Use /model to list available models.`,
};
}
const key = modelKey(resolved.ref.provider, resolved.ref.model);
if (allowedModelKeys.size > 0 && !allowedModelKeys.has(key)) {
return {
error: `Model "${resolved.ref.provider}/${resolved.ref.model}" is not allowed. Use /model to list available models.`,
};
}
const isDefault =
resolved.ref.provider === defaultProvider &&
resolved.ref.model === defaultModel;
return {
selection: {
provider: resolved.ref.provider,
model: resolved.ref.model,
isDefault,
alias: resolved.alias,
},
};
}
export function resolveContextTokens(params: {
agentCfg: ClawdisConfig["agent"] | undefined;
model: string;
}): number {
return (
params.agentCfg?.contextTokens ??
lookupContextTokens(params.model) ??
DEFAULT_CONTEXT_TOKENS
);
}
+479
View File
@@ -0,0 +1,479 @@
import type { SkillSnapshot } from "../../agents/skills.js";
import { parseDurationMs } from "../../cli/parse-duration.js";
import type { ClawdisConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js";
import { defaultRuntime } from "../../runtime.js";
import type { ElevatedLevel, ThinkLevel, VerboseLevel } from "./directives.js";
export type QueueMode =
| "steer"
| "followup"
| "collect"
| "steer-backlog"
| "interrupt"
| "queue";
export type QueueDropPolicy = "old" | "new" | "summarize";
export type QueueSettings = {
mode: QueueMode;
debounceMs?: number;
cap?: number;
dropPolicy?: QueueDropPolicy;
};
export type FollowupRun = {
prompt: string;
summaryLine?: string;
enqueuedAt: number;
run: {
sessionId: string;
sessionKey?: string;
surface?: string;
sessionFile: string;
workspaceDir: string;
config: ClawdisConfig;
skillsSnapshot?: SkillSnapshot;
provider: string;
model: string;
thinkLevel?: ThinkLevel;
verboseLevel?: VerboseLevel;
elevatedLevel?: ElevatedLevel;
bashElevated?: {
enabled: boolean;
allowed: boolean;
defaultLevel: ElevatedLevel;
};
timeoutMs: number;
blockReplyBreak: "text_end" | "message_end";
ownerNumbers?: string[];
extraSystemPrompt?: string;
enforceFinalTag?: boolean;
};
};
type FollowupQueueState = {
items: FollowupRun[];
draining: boolean;
lastEnqueuedAt: number;
mode: QueueMode;
debounceMs: number;
cap: number;
dropPolicy: QueueDropPolicy;
droppedCount: number;
summaryLines: string[];
lastRun?: FollowupRun["run"];
};
const DEFAULT_QUEUE_DEBOUNCE_MS = 1000;
const DEFAULT_QUEUE_CAP = 20;
const DEFAULT_QUEUE_DROP: QueueDropPolicy = "summarize";
const FOLLOWUP_QUEUES = new Map<string, FollowupQueueState>();
function normalizeQueueMode(raw?: string): QueueMode | undefined {
if (!raw) return undefined;
const cleaned = raw.trim().toLowerCase();
if (cleaned === "queue" || cleaned === "queued") return "steer";
if (
cleaned === "interrupt" ||
cleaned === "interrupts" ||
cleaned === "abort"
)
return "interrupt";
if (cleaned === "steer" || cleaned === "steering") return "steer";
if (
cleaned === "followup" ||
cleaned === "follow-ups" ||
cleaned === "followups"
)
return "followup";
if (cleaned === "collect" || cleaned === "coalesce") return "collect";
if (
cleaned === "steer+backlog" ||
cleaned === "steer-backlog" ||
cleaned === "steer_backlog"
)
return "steer-backlog";
return undefined;
}
function normalizeQueueDropPolicy(raw?: string): QueueDropPolicy | undefined {
if (!raw) return undefined;
const cleaned = raw.trim().toLowerCase();
if (cleaned === "old" || cleaned === "oldest") return "old";
if (cleaned === "new" || cleaned === "newest") return "new";
if (cleaned === "summarize" || cleaned === "summary") return "summarize";
return undefined;
}
function parseQueueDebounce(raw?: string): number | undefined {
if (!raw) return undefined;
try {
const parsed = parseDurationMs(raw.trim(), { defaultUnit: "ms" });
if (!parsed || parsed < 0) return undefined;
return Math.round(parsed);
} catch {
return undefined;
}
}
function parseQueueCap(raw?: string): number | undefined {
if (!raw) return undefined;
const num = Number(raw);
if (!Number.isFinite(num)) return undefined;
const cap = Math.floor(num);
if (cap < 1) return undefined;
return cap;
}
function parseQueueDirectiveArgs(raw: string): {
consumed: number;
queueMode?: QueueMode;
queueReset: boolean;
rawMode?: string;
debounceMs?: number;
cap?: number;
dropPolicy?: QueueDropPolicy;
rawDebounce?: string;
rawCap?: string;
rawDrop?: string;
hasOptions: boolean;
} {
let i = 0;
const len = raw.length;
while (i < len && /\s/.test(raw[i])) i += 1;
if (raw[i] === ":") {
i += 1;
while (i < len && /\s/.test(raw[i])) i += 1;
}
let consumed = i;
let queueMode: QueueMode | undefined;
let queueReset = false;
let rawMode: string | undefined;
let debounceMs: number | undefined;
let cap: number | undefined;
let dropPolicy: QueueDropPolicy | undefined;
let rawDebounce: string | undefined;
let rawCap: string | undefined;
let rawDrop: string | undefined;
let hasOptions = false;
const takeToken = (): string | null => {
if (i >= len) return null;
const start = i;
while (i < len && !/\s/.test(raw[i])) i += 1;
if (start === i) return null;
const token = raw.slice(start, i);
while (i < len && /\s/.test(raw[i])) i += 1;
return token;
};
while (i < len) {
const token = takeToken();
if (!token) break;
const lowered = token.trim().toLowerCase();
if (lowered === "default" || lowered === "reset" || lowered === "clear") {
queueReset = true;
consumed = i;
break;
}
if (lowered.startsWith("debounce:") || lowered.startsWith("debounce=")) {
rawDebounce = token.split(/[:=]/)[1] ?? "";
debounceMs = parseQueueDebounce(rawDebounce);
hasOptions = true;
consumed = i;
continue;
}
if (lowered.startsWith("cap:") || lowered.startsWith("cap=")) {
rawCap = token.split(/[:=]/)[1] ?? "";
cap = parseQueueCap(rawCap);
hasOptions = true;
consumed = i;
continue;
}
if (lowered.startsWith("drop:") || lowered.startsWith("drop=")) {
rawDrop = token.split(/[:=]/)[1] ?? "";
dropPolicy = normalizeQueueDropPolicy(rawDrop);
hasOptions = true;
consumed = i;
continue;
}
const mode = normalizeQueueMode(token);
if (mode) {
queueMode = mode;
rawMode = token;
consumed = i;
continue;
}
// Stop at first unrecognized token.
break;
}
return {
consumed,
queueMode,
queueReset,
rawMode,
debounceMs,
cap,
dropPolicy,
rawDebounce,
rawCap,
rawDrop,
hasOptions,
};
}
export function extractQueueDirective(body?: string): {
cleaned: string;
queueMode?: QueueMode;
queueReset: boolean;
rawMode?: string;
hasDirective: boolean;
debounceMs?: number;
cap?: number;
dropPolicy?: QueueDropPolicy;
rawDebounce?: string;
rawCap?: string;
rawDrop?: string;
hasOptions: boolean;
} {
if (!body)
return {
cleaned: "",
hasDirective: false,
queueReset: false,
hasOptions: false,
};
const re = /(?:^|\s)\/queue(?=$|\s|:)/i;
const match = re.exec(body);
if (!match) {
return {
cleaned: body.trim(),
hasDirective: false,
queueReset: false,
hasOptions: false,
};
}
const start = match.index + match[0].indexOf("/queue");
const argsStart = start + "/queue".length;
const args = body.slice(argsStart);
const parsed = parseQueueDirectiveArgs(args);
const cleanedRaw =
body.slice(0, start) + body.slice(argsStart + parsed.consumed);
const cleaned = cleanedRaw.replace(/\s+/g, " ").trim();
return {
cleaned,
queueMode: parsed.queueMode,
queueReset: parsed.queueReset,
rawMode: parsed.rawMode,
debounceMs: parsed.debounceMs,
cap: parsed.cap,
dropPolicy: parsed.dropPolicy,
rawDebounce: parsed.rawDebounce,
rawCap: parsed.rawCap,
rawDrop: parsed.rawDrop,
hasDirective: true,
hasOptions: parsed.hasOptions,
};
}
function elideText(text: string, limit = 140): string {
if (text.length <= limit) return text;
return `${text.slice(0, Math.max(0, limit - 1)).trimEnd()}…`;
}
function buildQueueSummaryLine(run: FollowupRun): string {
const base = run.summaryLine?.trim() || run.prompt.trim();
const cleaned = base.replace(/\s+/g, " ").trim();
return elideText(cleaned, 160);
}
function getFollowupQueue(
key: string,
settings: QueueSettings,
): FollowupQueueState {
const existing = FOLLOWUP_QUEUES.get(key);
if (existing) {
existing.mode = settings.mode;
existing.debounceMs =
typeof settings.debounceMs === "number"
? Math.max(0, settings.debounceMs)
: existing.debounceMs;
existing.cap =
typeof settings.cap === "number" && settings.cap > 0
? Math.floor(settings.cap)
: existing.cap;
existing.dropPolicy = settings.dropPolicy ?? existing.dropPolicy;
return existing;
}
const created: FollowupQueueState = {
items: [],
draining: false,
lastEnqueuedAt: 0,
mode: settings.mode,
debounceMs:
typeof settings.debounceMs === "number"
? Math.max(0, settings.debounceMs)
: DEFAULT_QUEUE_DEBOUNCE_MS,
cap:
typeof settings.cap === "number" && settings.cap > 0
? Math.floor(settings.cap)
: DEFAULT_QUEUE_CAP,
dropPolicy: settings.dropPolicy ?? DEFAULT_QUEUE_DROP,
droppedCount: 0,
summaryLines: [],
};
FOLLOWUP_QUEUES.set(key, created);
return created;
}
export function enqueueFollowupRun(
key: string,
run: FollowupRun,
settings: QueueSettings,
): boolean {
const queue = getFollowupQueue(key, settings);
queue.lastEnqueuedAt = Date.now();
queue.lastRun = run.run;
const cap = queue.cap;
if (cap > 0 && queue.items.length >= cap) {
if (queue.dropPolicy === "new") {
return false;
}
const dropCount = queue.items.length - cap + 1;
const dropped = queue.items.splice(0, dropCount);
if (queue.dropPolicy === "summarize") {
for (const item of dropped) {
queue.droppedCount += 1;
queue.summaryLines.push(buildQueueSummaryLine(item));
}
while (queue.summaryLines.length > cap) queue.summaryLines.shift();
}
}
queue.items.push(run);
return true;
}
async function waitForQueueDebounce(queue: FollowupQueueState): Promise<void> {
const debounceMs = Math.max(0, queue.debounceMs);
if (debounceMs <= 0) return;
while (true) {
const since = Date.now() - queue.lastEnqueuedAt;
if (since >= debounceMs) return;
await new Promise((resolve) => setTimeout(resolve, debounceMs - since));
}
}
function buildSummaryPrompt(queue: FollowupQueueState): string | undefined {
if (queue.dropPolicy !== "summarize" || queue.droppedCount <= 0) {
return undefined;
}
const lines = [
`[Queue overflow] Dropped ${queue.droppedCount} message${queue.droppedCount === 1 ? "" : "s"} due to cap.`,
];
if (queue.summaryLines.length > 0) {
lines.push("Summary:");
for (const line of queue.summaryLines) {
lines.push(`- ${line}`);
}
}
queue.droppedCount = 0;
queue.summaryLines = [];
return lines.join("\n");
}
function buildCollectPrompt(items: FollowupRun[], summary?: string): string {
const blocks: string[] = ["[Queued messages while agent was busy]"];
if (summary) {
blocks.push(summary);
}
items.forEach((item, idx) => {
blocks.push(`---\nQueued #${idx + 1}\n${item.prompt}`.trim());
});
return blocks.join("\n\n");
}
export function scheduleFollowupDrain(
key: string,
runFollowup: (run: FollowupRun) => Promise<void>,
): void {
const queue = FOLLOWUP_QUEUES.get(key);
if (!queue || queue.draining) return;
queue.draining = true;
void (async () => {
try {
while (queue.items.length > 0 || queue.droppedCount > 0) {
await waitForQueueDebounce(queue);
if (queue.mode === "collect") {
const items = queue.items.splice(0, queue.items.length);
const summary = buildSummaryPrompt(queue);
const run = items.at(-1)?.run ?? queue.lastRun;
if (!run) break;
const prompt = buildCollectPrompt(items, summary);
await runFollowup({
prompt,
run,
enqueuedAt: Date.now(),
});
continue;
}
const summaryPrompt = buildSummaryPrompt(queue);
if (summaryPrompt) {
const run = queue.lastRun;
if (!run) break;
await runFollowup({
prompt: summaryPrompt,
run,
enqueuedAt: Date.now(),
});
continue;
}
const next = queue.items.shift();
if (!next) break;
await runFollowup(next);
}
} catch (err) {
defaultRuntime.error?.(
`followup queue drain failed for ${key}: ${String(err)}`,
);
} finally {
queue.draining = false;
if (queue.items.length === 0 && queue.droppedCount === 0) {
FOLLOWUP_QUEUES.delete(key);
} else {
scheduleFollowupDrain(key, runFollowup);
}
}
})();
}
function defaultQueueModeForSurface(surface?: string): QueueMode {
const normalized = surface?.trim().toLowerCase();
if (normalized === "discord") return "collect";
if (normalized === "webchat") return "collect";
if (normalized === "whatsapp") return "collect";
if (normalized === "telegram") return "collect";
if (normalized === "imessage") return "collect";
if (normalized === "signal") return "collect";
return "collect";
}
export function resolveQueueSettings(params: {
cfg: ClawdisConfig;
surface?: string;
sessionEntry?: SessionEntry;
inlineMode?: QueueMode;
inlineOptions?: Partial<QueueSettings>;
}): QueueSettings {
const surfaceKey = params.surface?.trim().toLowerCase();
const queueCfg = params.cfg.routing?.queue;
const surfaceModeRaw =
surfaceKey && queueCfg?.bySurface
? (queueCfg.bySurface as Record<string, string | undefined>)[surfaceKey]
: undefined;
const resolvedMode =
params.inlineMode ??
normalizeQueueMode(params.sessionEntry?.queueMode) ??
normalizeQueueMode(surfaceModeRaw) ??
normalizeQueueMode(queueCfg?.mode) ??
defaultQueueModeForSurface(surfaceKey);
const debounceRaw =
params.inlineOptions?.debounceMs ??
params.sessionEntry?.queueDebounceMs ??
queueCfg?.debounceMs ??
DEFAULT_QUEUE_DEBOUNCE_MS;
const capRaw =
params.inlineOptions?.cap ??
params.sessionEntry?.queueCap ??
queueCfg?.cap ??
DEFAULT_QUEUE_CAP;
const dropRaw =
params.inlineOptions?.dropPolicy ??
params.sessionEntry?.queueDrop ??
normalizeQueueDropPolicy(queueCfg?.drop) ??
DEFAULT_QUEUE_DROP;
return {
mode: resolvedMode,
debounceMs:
typeof debounceRaw === "number" ? Math.max(0, debounceRaw) : undefined,
cap:
typeof capRaw === "number" ? Math.max(1, Math.floor(capRaw)) : undefined,
dropPolicy: dropRaw,
};
}
+35
View File
@@ -0,0 +1,35 @@
export function extractReplyToTag(
text?: string,
currentMessageId?: string,
): {
cleaned: string;
replyToId?: string;
hasTag: boolean;
} {
if (!text) return { cleaned: "", hasTag: false };
let cleaned = text;
let replyToId: string | undefined;
let hasTag = false;
const currentMatch = cleaned.match(/\[\[reply_to_current\]\]/i);
if (currentMatch) {
cleaned = cleaned.replace(/\[\[reply_to_current\]\]/gi, " ");
hasTag = true;
if (currentMessageId?.trim()) {
replyToId = currentMessageId.trim();
}
}
const idMatch = cleaned.match(/\[\[reply_to:([^\]\n]+)\]\]/i);
if (idMatch?.[1]) {
cleaned = cleaned.replace(/\[\[reply_to:[^\]\n]+\]\]/gi, " ");
replyToId = idMatch[1].trim();
hasTag = true;
}
cleaned = cleaned
.replace(/[ \t]+/g, " ")
.replace(/[ \t]*\n[ \t]*/g, "\n")
.trim();
return { cleaned, replyToId, hasTag };
}
+125
View File
@@ -0,0 +1,125 @@
import crypto from "node:crypto";
import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js";
import type { ClawdisConfig } from "../../config/config.js";
import { type SessionEntry, saveSessionStore } from "../../config/sessions.js";
import { buildProviderSummary } from "../../infra/provider-summary.js";
import { drainSystemEvents } from "../../infra/system-events.js";
export async function prependSystemEvents(params: {
cfg: ClawdisConfig;
isMainSession: boolean;
isNewSession: boolean;
prefixedBodyBase: string;
}): Promise<string> {
if (!params.isMainSession) return params.prefixedBodyBase;
const compactSystemEvent = (line: string): string | null => {
const trimmed = line.trim();
if (!trimmed) return null;
const lower = trimmed.toLowerCase();
if (lower.includes("reason periodic")) return null;
if (lower.includes("heartbeat")) return null;
if (trimmed.startsWith("Node:")) {
return trimmed.replace(/ · last input [^·]+/i, "").trim();
}
return trimmed;
};
const systemLines: string[] = [];
const queued = drainSystemEvents();
systemLines.push(
...queued.map(compactSystemEvent).filter((v): v is string => Boolean(v)),
);
if (params.isNewSession) {
const summary = await buildProviderSummary(params.cfg);
if (summary.length > 0) systemLines.unshift(...summary);
}
if (systemLines.length === 0) return params.prefixedBodyBase;
const block = systemLines.map((l) => `System: ${l}`).join("\n");
return `${block}\n\n${params.prefixedBodyBase}`;
}
export async function ensureSkillSnapshot(params: {
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionKey?: string;
storePath?: string;
sessionId?: string;
isFirstTurnInSession: boolean;
workspaceDir: string;
cfg: ClawdisConfig;
}): Promise<{
sessionEntry?: SessionEntry;
skillsSnapshot?: SessionEntry["skillsSnapshot"];
systemSent: boolean;
}> {
const {
sessionEntry,
sessionStore,
sessionKey,
storePath,
sessionId,
isFirstTurnInSession,
workspaceDir,
cfg,
} = params;
let nextEntry = sessionEntry;
let systemSent = sessionEntry?.systemSent ?? false;
if (isFirstTurnInSession && sessionStore && sessionKey) {
const current = nextEntry ??
sessionStore[sessionKey] ?? {
sessionId: sessionId ?? crypto.randomUUID(),
updatedAt: Date.now(),
};
const skillSnapshot =
isFirstTurnInSession || !current.skillsSnapshot
? buildWorkspaceSkillSnapshot(workspaceDir, { config: cfg })
: current.skillsSnapshot;
nextEntry = {
...current,
sessionId: sessionId ?? current.sessionId ?? crypto.randomUUID(),
updatedAt: Date.now(),
systemSent: true,
skillsSnapshot: skillSnapshot,
};
sessionStore[sessionKey] = nextEntry;
if (storePath) {
await saveSessionStore(storePath, sessionStore);
}
systemSent = true;
}
const skillsSnapshot =
nextEntry?.skillsSnapshot ??
(isFirstTurnInSession
? undefined
: buildWorkspaceSkillSnapshot(workspaceDir, { config: cfg }));
if (
skillsSnapshot &&
sessionStore &&
sessionKey &&
!isFirstTurnInSession &&
!nextEntry?.skillsSnapshot
) {
const current = nextEntry ?? {
sessionId: sessionId ?? crypto.randomUUID(),
updatedAt: Date.now(),
};
nextEntry = {
...current,
sessionId: sessionId ?? current.sessionId ?? crypto.randomUUID(),
updatedAt: Date.now(),
skillsSnapshot,
};
sessionStore[sessionKey] = nextEntry;
if (storePath) {
await saveSessionStore(storePath, sessionStore);
}
}
return { sessionEntry: nextEntry, skillsSnapshot, systemSent };
}
+208
View File
@@ -0,0 +1,208 @@
import crypto from "node:crypto";
import type { ClawdisConfig } from "../../config/config.js";
import {
buildGroupDisplayName,
DEFAULT_IDLE_MINUTES,
DEFAULT_RESET_TRIGGERS,
type GroupKeyResolution,
loadSessionStore,
resolveGroupSessionKey,
resolveSessionKey,
resolveStorePath,
type SessionEntry,
type SessionScope,
saveSessionStore,
} from "../../config/sessions.js";
import type { MsgContext, TemplateContext } from "../templating.js";
import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
export type SessionInitResult = {
sessionCtx: TemplateContext;
sessionEntry: SessionEntry;
sessionStore: Record<string, SessionEntry>;
sessionKey: string;
sessionId: string;
isNewSession: boolean;
systemSent: boolean;
abortedLastRun: boolean;
storePath: string;
sessionScope: SessionScope;
groupResolution?: GroupKeyResolution;
isGroup: boolean;
bodyStripped?: string;
triggerBodyNormalized: string;
};
export async function initSessionState(params: {
ctx: MsgContext;
cfg: ClawdisConfig;
}): Promise<SessionInitResult> {
const { ctx, cfg } = params;
const sessionCfg = cfg.session;
const mainKey = sessionCfg?.mainKey ?? "main";
const resetTriggers = sessionCfg?.resetTriggers?.length
? sessionCfg.resetTriggers
: DEFAULT_RESET_TRIGGERS;
const idleMinutes = Math.max(
sessionCfg?.idleMinutes ?? DEFAULT_IDLE_MINUTES,
1,
);
const sessionScope = sessionCfg?.scope ?? "per-sender";
const storePath = resolveStorePath(sessionCfg?.store);
const sessionStore: Record<string, SessionEntry> =
loadSessionStore(storePath);
let sessionKey: string | undefined;
let sessionEntry: SessionEntry | undefined;
let sessionId: string | undefined;
let isNewSession = false;
let bodyStripped: string | undefined;
let systemSent = false;
let abortedLastRun = false;
let persistedThinking: string | undefined;
let persistedVerbose: string | undefined;
let persistedModelOverride: string | undefined;
let persistedProviderOverride: string | undefined;
const groupResolution = resolveGroupSessionKey(ctx) ?? undefined;
const isGroup =
ctx.ChatType?.trim().toLowerCase() === "group" || Boolean(groupResolution);
const triggerBodyNormalized = stripStructuralPrefixes(ctx.Body ?? "")
.trim()
.toLowerCase();
const rawBody = ctx.Body ?? "";
const trimmedBody = rawBody.trim();
// Timestamp/message prefixes (e.g. "[Dec 4 17:35] ") are added by the
// web inbox before we get here. They prevented reset triggers like "/new"
// from matching, so strip structural wrappers when checking for resets.
const strippedForReset = isGroup
? stripMentions(triggerBodyNormalized, ctx, cfg)
: triggerBodyNormalized;
for (const trigger of resetTriggers) {
if (!trigger) continue;
if (trimmedBody === trigger || strippedForReset === trigger) {
isNewSession = true;
bodyStripped = "";
break;
}
const triggerPrefix = `${trigger} `;
if (
trimmedBody.startsWith(triggerPrefix) ||
strippedForReset.startsWith(triggerPrefix)
) {
isNewSession = true;
bodyStripped = strippedForReset.slice(trigger.length).trimStart();
break;
}
}
sessionKey = resolveSessionKey(sessionScope, ctx, mainKey);
if (groupResolution?.legacyKey && groupResolution.legacyKey !== sessionKey) {
const legacyEntry = sessionStore[groupResolution.legacyKey];
if (legacyEntry && !sessionStore[sessionKey]) {
sessionStore[sessionKey] = legacyEntry;
delete sessionStore[groupResolution.legacyKey];
}
}
const entry = sessionStore[sessionKey];
const idleMs = idleMinutes * 60_000;
const freshEntry = entry && Date.now() - entry.updatedAt <= idleMs;
if (!isNewSession && freshEntry) {
sessionId = entry.sessionId;
systemSent = entry.systemSent ?? false;
abortedLastRun = entry.abortedLastRun ?? false;
persistedThinking = entry.thinkingLevel;
persistedVerbose = entry.verboseLevel;
persistedModelOverride = entry.modelOverride;
persistedProviderOverride = entry.providerOverride;
} else {
sessionId = crypto.randomUUID();
isNewSession = true;
systemSent = false;
abortedLastRun = false;
}
const baseEntry = !isNewSession && freshEntry ? entry : undefined;
sessionEntry = {
...baseEntry,
sessionId,
updatedAt: Date.now(),
systemSent,
abortedLastRun,
// Persist previously stored thinking/verbose levels when present.
thinkingLevel: persistedThinking ?? baseEntry?.thinkingLevel,
verboseLevel: persistedVerbose ?? baseEntry?.verboseLevel,
modelOverride: persistedModelOverride ?? baseEntry?.modelOverride,
providerOverride: persistedProviderOverride ?? baseEntry?.providerOverride,
sendPolicy: baseEntry?.sendPolicy,
queueMode: baseEntry?.queueMode,
queueDebounceMs: baseEntry?.queueDebounceMs,
queueCap: baseEntry?.queueCap,
queueDrop: baseEntry?.queueDrop,
displayName: baseEntry?.displayName,
chatType: baseEntry?.chatType,
surface: baseEntry?.surface,
subject: baseEntry?.subject,
room: baseEntry?.room,
space: baseEntry?.space,
};
if (groupResolution?.surface) {
const surface = groupResolution.surface;
const subject = ctx.GroupSubject?.trim();
const space = ctx.GroupSpace?.trim();
const explicitRoom = ctx.GroupRoom?.trim();
const isRoomSurface = surface === "discord" || surface === "slack";
const nextRoom =
explicitRoom ??
(isRoomSurface && subject && subject.startsWith("#")
? subject
: undefined);
const nextSubject = nextRoom ? undefined : subject;
sessionEntry.chatType = groupResolution.chatType ?? "group";
sessionEntry.surface = surface;
if (nextSubject) sessionEntry.subject = nextSubject;
if (nextRoom) sessionEntry.room = nextRoom;
if (space) sessionEntry.space = space;
sessionEntry.displayName = buildGroupDisplayName({
surface: sessionEntry.surface,
subject: sessionEntry.subject,
room: sessionEntry.room,
space: sessionEntry.space,
id: groupResolution.id,
key: sessionKey,
});
} else if (!sessionEntry.chatType) {
sessionEntry.chatType = "direct";
}
sessionStore[sessionKey] = sessionEntry;
await saveSessionStore(storePath, sessionStore);
const sessionCtx: TemplateContext = {
...ctx,
BodyStripped: bodyStripped ?? ctx.Body,
SessionId: sessionId,
IsNewSession: isNewSession ? "true" : "false",
};
return {
sessionCtx,
sessionEntry,
sessionStore,
sessionKey,
sessionId: sessionId ?? crypto.randomUUID(),
isNewSession,
systemSent,
abortedLastRun,
storePath,
sessionScope,
groupResolution,
isGroup,
bodyStripped,
triggerBodyNormalized,
};
}
+95
View File
@@ -0,0 +1,95 @@
export type TypingController = {
onReplyStart: () => Promise<void>;
startTypingLoop: () => Promise<void>;
startTypingOnText: (text?: string) => Promise<void>;
refreshTypingTtl: () => void;
cleanup: () => void;
};
export function createTypingController(params: {
onReplyStart?: () => Promise<void> | void;
typingIntervalSeconds?: number;
typingTtlMs?: number;
silentToken?: string;
log?: (message: string) => void;
}): TypingController {
const {
onReplyStart,
typingIntervalSeconds = 6,
typingTtlMs = 2 * 60_000,
silentToken,
log,
} = params;
let started = false;
let typingTimer: NodeJS.Timeout | undefined;
let typingTtlTimer: NodeJS.Timeout | undefined;
const typingIntervalMs = typingIntervalSeconds * 1000;
const formatTypingTtl = (ms: number) => {
if (ms % 60_000 === 0) return `${ms / 60_000}m`;
return `${Math.round(ms / 1000)}s`;
};
const cleanup = () => {
if (typingTtlTimer) {
clearTimeout(typingTtlTimer);
typingTtlTimer = undefined;
}
if (typingTimer) {
clearInterval(typingTimer);
typingTimer = undefined;
}
};
const refreshTypingTtl = () => {
if (!typingIntervalMs || typingIntervalMs <= 0) return;
if (typingTtlMs <= 0) return;
if (typingTtlTimer) {
clearTimeout(typingTtlTimer);
}
typingTtlTimer = setTimeout(() => {
if (!typingTimer) return;
log?.(
`typing TTL reached (${formatTypingTtl(typingTtlMs)}); stopping typing indicator`,
);
cleanup();
}, typingTtlMs);
};
const triggerTyping = async () => {
await onReplyStart?.();
};
const ensureStart = async () => {
if (started) return;
started = true;
await triggerTyping();
};
const startTypingLoop = async () => {
if (!onReplyStart) return;
if (typingIntervalMs <= 0) return;
if (typingTimer) return;
await ensureStart();
refreshTypingTtl();
typingTimer = setInterval(() => {
void triggerTyping();
}, typingIntervalMs);
};
const startTypingOnText = async (text?: string) => {
const trimmed = text?.trim();
if (!trimmed) return;
if (silentToken && trimmed === silentToken) return;
refreshTypingTtl();
await startTypingLoop();
};
return {
onReplyStart: ensureStart,
startTypingLoop,
startTypingOnText,
refreshTypingTtl,
cleanup,
};
}
+3 -1
View File
@@ -161,6 +161,8 @@ export function buildStatusMessage(args: StatusArgs): string {
const thinkLevel = args.resolvedThink ?? args.agent?.thinkingDefault ?? "off";
const verboseLevel =
args.resolvedVerbose ?? args.agent?.verboseDefault ?? "off";
const elevatedLevel =
args.sessionEntry?.elevatedLevel ?? args.agent?.elevatedDefault ?? "on";
const webLine = (() => {
if (args.webLinked === false) {
@@ -200,7 +202,7 @@ export function buildStatusMessage(args: StatusArgs): string {
contextTokens ?? null,
)}${entry?.abortedLastRun ? " • last run aborted" : ""}`;
const optionsLine = `Options: thinking=${thinkLevel} | verbose=${verboseLevel} (set with /think <level>, /verbose on|off, /model <id>)`;
const optionsLine = `Options: thinking=${thinkLevel} | verbose=${verboseLevel} | elevated=${elevatedLevel} (set with /think <level>, /verbose on|off, /elevated on|off, /model <id>)`;
const modelLabel = model ? `${resolved.provider}/${model}` : "unknown";
+2
View File
@@ -17,6 +17,8 @@ export type MsgContext = {
GroupSpace?: string;
GroupMembers?: string;
SenderName?: string;
SenderUsername?: string;
SenderTag?: string;
SenderE164?: string;
Surface?: string;
WasMentioned?: boolean;
+12
View File
@@ -1,5 +1,6 @@
export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high";
export type VerboseLevel = "off" | "on";
export type ElevatedLevel = "off" | "on";
// Normalize user-provided thinking level strings to the canonical enum.
export function normalizeThinkLevel(
@@ -39,3 +40,14 @@ export function normalizeVerboseLevel(
if (["on", "full", "true", "yes", "1"].includes(key)) return "on";
return undefined;
}
// Normalize elevated flags used to toggle elevated bash permissions.
export function normalizeElevatedLevel(
raw?: string | null,
): ElevatedLevel | undefined {
if (!raw) return undefined;
const key = raw.toLowerCase();
if (["off", "false", "no", "0"].includes(key)) return "off";
if (["on", "true", "yes", "1"].includes(key)) return "on";
return undefined;
}
+1 -5
View File
@@ -30,16 +30,12 @@ export async function startBrowserBridgeServer(params: {
const state: BrowserServerState = {
server: null as unknown as Server,
port,
cdpPort: params.resolved.cdpPort,
running: null,
resolved: params.resolved,
profiles: new Map(),
};
const ctx = createBrowserRouteContext({
getState: () => state,
setRunning: (running) => {
state.running = running;
},
});
registerBrowserRoutes(app, ctx);
+33 -22
View File
@@ -8,7 +8,10 @@ import { ensurePortAvailable } from "../infra/ports.js";
import { createSubsystemLogger } from "../logging.js";
import { CONFIG_DIR } from "../utils.js";
import { normalizeCdpWsUrl } from "./cdp.js";
import type { ResolvedBrowserConfig } from "./config.js";
import type {
ResolvedBrowserConfig,
ResolvedBrowserProfile,
} from "./config.js";
import {
DEFAULT_CLAWD_BROWSER_COLOR,
DEFAULT_CLAWD_BROWSER_PROFILE_NAME,
@@ -116,13 +119,10 @@ function resolveBrowserExecutable(
return null;
}
export function resolveClawdUserDataDir() {
return path.join(
CONFIG_DIR,
"browser",
DEFAULT_CLAWD_BROWSER_PROFILE_NAME,
"user-data",
);
export function resolveClawdUserDataDir(
profileName = DEFAULT_CLAWD_BROWSER_PROFILE_NAME,
) {
return path.join(CONFIG_DIR, "browser", profileName, "user-data");
}
function decoratedMarkerPath(userDataDir: string) {
@@ -255,9 +255,9 @@ function isProfileDecorated(
*/
export function decorateClawdProfile(
userDataDir: string,
opts?: { color?: string },
opts?: { name?: string; color?: string },
) {
const desiredName = DEFAULT_CLAWD_BROWSER_PROFILE_NAME;
const desiredName = opts?.name ?? DEFAULT_CLAWD_BROWSER_PROFILE_NAME;
const desiredColor = (
opts?.color ?? DEFAULT_CLAWD_BROWSER_COLOR
).toUpperCase();
@@ -433,8 +433,14 @@ export async function isChromeCdpReady(
export async function launchClawdChrome(
resolved: ResolvedBrowserConfig,
profile: ResolvedBrowserProfile,
): Promise<RunningChrome> {
await ensurePortAvailable(resolved.cdpPort);
if (!profile.cdpIsLoopback) {
throw new Error(
`Profile "${profile.name}" is remote; cannot launch local Chrome.`,
);
}
await ensurePortAvailable(profile.cdpPort);
const exe = resolveBrowserExecutable(resolved);
if (!exe) {
@@ -443,19 +449,19 @@ export async function launchClawdChrome(
);
}
const userDataDir = resolveClawdUserDataDir();
const userDataDir = resolveClawdUserDataDir(profile.name);
fs.mkdirSync(userDataDir, { recursive: true });
const needsDecorate = !isProfileDecorated(
userDataDir,
DEFAULT_CLAWD_BROWSER_PROFILE_NAME,
(resolved.color ?? DEFAULT_CLAWD_BROWSER_COLOR).toUpperCase(),
profile.name,
(profile.color ?? DEFAULT_CLAWD_BROWSER_COLOR).toUpperCase(),
);
// First launch to create preference files if missing, then decorate and relaunch.
const spawnOnce = () => {
const args: string[] = [
`--remote-debugging-port=${resolved.cdpPort}`,
`--remote-debugging-port=${profile.cdpPort}`,
`--user-data-dir=${userDataDir}`,
"--no-first-run",
"--no-default-browser-check",
@@ -521,8 +527,11 @@ export async function launchClawdChrome(
if (needsDecorate) {
try {
decorateClawdProfile(userDataDir, { color: resolved.color });
log.info(`🦞 clawd browser profile decorated (${resolved.color})`);
decorateClawdProfile(userDataDir, {
name: profile.name,
color: profile.color,
});
log.info(`🦞 clawd browser profile decorated (${profile.color})`);
} catch (err) {
log.warn(`clawd browser profile decoration failed: ${String(err)}`);
}
@@ -532,29 +541,31 @@ export async function launchClawdChrome(
// Wait for CDP to come up.
const readyDeadline = Date.now() + 15_000;
while (Date.now() < readyDeadline) {
if (await isChromeReachable(resolved.cdpUrl, 500)) break;
if (await isChromeReachable(profile.cdpUrl, 500)) break;
await new Promise((r) => setTimeout(r, 200));
}
if (!(await isChromeReachable(resolved.cdpUrl, 500))) {
if (!(await isChromeReachable(profile.cdpUrl, 500))) {
try {
proc.kill("SIGKILL");
} catch {
// ignore
}
throw new Error(`Failed to start Chrome CDP on port ${resolved.cdpPort}.`);
throw new Error(
`Failed to start Chrome CDP on port ${profile.cdpPort} for profile "${profile.name}".`,
);
}
const pid = proc.pid ?? -1;
log.info(
`🦞 clawd browser started (${exe.kind}) on 127.0.0.1:${resolved.cdpPort} (pid ${pid})`,
`🦞 clawd browser started (${exe.kind}) profile "${profile.name}" on 127.0.0.1:${profile.cdpPort} (pid ${pid})`,
);
return {
pid,
exe,
userDataDir,
cdpPort: resolved.cdpPort,
cdpPort: profile.cdpPort,
startedAt,
proc,
};
+40 -21
View File
@@ -5,6 +5,10 @@ import type {
} from "./client-actions-types.js";
import { fetchBrowserJson } from "./client-fetch.js";
function buildProfileQuery(profile?: string): string {
return profile ? `?profile=${encodeURIComponent(profile)}` : "";
}
export type BrowserFormField = {
ref: string;
type: string;
@@ -57,14 +61,18 @@ export type BrowserActResponse = {
export async function browserNavigate(
baseUrl: string,
opts: { url: string; targetId?: string },
opts: { url: string; targetId?: string; profile?: string },
): Promise<BrowserActionTabResult> {
return await fetchBrowserJson<BrowserActionTabResult>(`${baseUrl}/navigate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: opts.url, targetId: opts.targetId }),
timeoutMs: 20000,
});
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTabResult>(
`${baseUrl}/navigate${q}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: opts.url, targetId: opts.targetId }),
timeoutMs: 20000,
},
);
}
export async function browserArmDialog(
@@ -74,19 +82,24 @@ export async function browserArmDialog(
promptText?: string;
targetId?: string;
timeoutMs?: number;
profile?: string;
},
): Promise<BrowserActionOk> {
return await fetchBrowserJson<BrowserActionOk>(`${baseUrl}/hooks/dialog`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
accept: opts.accept,
promptText: opts.promptText,
targetId: opts.targetId,
timeoutMs: opts.timeoutMs,
}),
timeoutMs: 20000,
});
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionOk>(
`${baseUrl}/hooks/dialog${q}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
accept: opts.accept,
promptText: opts.promptText,
targetId: opts.targetId,
timeoutMs: opts.timeoutMs,
}),
timeoutMs: 20000,
},
);
}
export async function browserArmFileChooser(
@@ -98,10 +111,12 @@ export async function browserArmFileChooser(
element?: string;
targetId?: string;
timeoutMs?: number;
profile?: string;
},
): Promise<BrowserActionOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionOk>(
`${baseUrl}/hooks/file-chooser`,
`${baseUrl}/hooks/file-chooser${q}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -121,8 +136,10 @@ export async function browserArmFileChooser(
export async function browserAct(
baseUrl: string,
req: BrowserActRequest,
opts?: { profile?: string },
): Promise<BrowserActResponse> {
return await fetchBrowserJson<BrowserActResponse>(`${baseUrl}/act`, {
const q = buildProfileQuery(opts?.profile);
return await fetchBrowserJson<BrowserActResponse>(`${baseUrl}/act${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(req),
@@ -138,10 +155,12 @@ export async function browserScreenshotAction(
ref?: string;
element?: string;
type?: "png" | "jpeg";
profile?: string;
},
): Promise<BrowserActionPathResult> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionPathResult>(
`${baseUrl}/screenshot`,
`${baseUrl}/screenshot${q}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
+9 -3
View File
@@ -2,13 +2,18 @@ import type { BrowserActionPathResult } from "./client-actions-types.js";
import { fetchBrowserJson } from "./client-fetch.js";
import type { BrowserConsoleMessage } from "./pw-session.js";
function buildProfileQuery(profile?: string): string {
return profile ? `?profile=${encodeURIComponent(profile)}` : "";
}
export async function browserConsoleMessages(
baseUrl: string,
opts: { level?: string; targetId?: string } = {},
opts: { level?: string; targetId?: string; profile?: string } = {},
): Promise<{ ok: true; messages: BrowserConsoleMessage[]; targetId: string }> {
const q = new URLSearchParams();
if (opts.level) q.set("level", opts.level);
if (opts.targetId) q.set("targetId", opts.targetId);
if (opts.profile) q.set("profile", opts.profile);
const suffix = q.toString() ? `?${q.toString()}` : "";
return await fetchBrowserJson<{
ok: true;
@@ -19,9 +24,10 @@ export async function browserConsoleMessages(
export async function browserPdfSave(
baseUrl: string,
opts: { targetId?: string } = {},
opts: { targetId?: string; profile?: string } = {},
): Promise<BrowserActionPathResult> {
return await fetchBrowserJson<BrowserActionPathResult>(`${baseUrl}/pdf`, {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionPathResult>(`${baseUrl}/pdf${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId }),
+118 -15
View File
@@ -5,6 +5,7 @@ import { resolveBrowserConfig } from "./config.js";
export type BrowserStatus = {
enabled: boolean;
controlUrl: string;
profile?: string;
running: boolean;
cdpReady?: boolean;
cdpHttp?: boolean;
@@ -20,6 +21,17 @@ export type BrowserStatus = {
attachOnly: boolean;
};
export type ProfileStatus = {
name: string;
cdpPort: number;
cdpUrl: string;
color: string;
running: boolean;
tabCount: number;
isDefault: boolean;
isRemote: boolean;
};
export type BrowserResetProfileResult = {
ok: true;
moved: boolean;
@@ -31,6 +43,7 @@ export type BrowserTab = {
targetId: string;
title: string;
url: string;
wsUrl?: string;
type?: string;
};
@@ -67,21 +80,47 @@ export function resolveBrowserControlUrl(overrideUrl?: string) {
return url.replace(/\/$/, "");
}
export async function browserStatus(baseUrl: string): Promise<BrowserStatus> {
return await fetchBrowserJson<BrowserStatus>(`${baseUrl}/`, {
function buildProfileQuery(profile?: string): string {
return profile ? `?profile=${encodeURIComponent(profile)}` : "";
}
export async function browserStatus(
baseUrl: string,
opts?: { profile?: string },
): Promise<BrowserStatus> {
const q = buildProfileQuery(opts?.profile);
return await fetchBrowserJson<BrowserStatus>(`${baseUrl}/${q}`, {
timeoutMs: 1500,
});
}
export async function browserStart(baseUrl: string): Promise<void> {
await fetchBrowserJson(`${baseUrl}/start`, {
export async function browserProfiles(
baseUrl: string,
): Promise<ProfileStatus[]> {
const res = await fetchBrowserJson<{ profiles: ProfileStatus[] }>(
`${baseUrl}/profiles`,
{ timeoutMs: 3000 },
);
return res.profiles ?? [];
}
export async function browserStart(
baseUrl: string,
opts?: { profile?: string },
): Promise<void> {
const q = buildProfileQuery(opts?.profile);
await fetchBrowserJson(`${baseUrl}/start${q}`, {
method: "POST",
timeoutMs: 15000,
});
}
export async function browserStop(baseUrl: string): Promise<void> {
await fetchBrowserJson(`${baseUrl}/stop`, {
export async function browserStop(
baseUrl: string,
opts?: { profile?: string },
): Promise<void> {
const q = buildProfileQuery(opts?.profile);
await fetchBrowserJson(`${baseUrl}/stop${q}`, {
method: "POST",
timeoutMs: 15000,
});
@@ -89,9 +128,11 @@ export async function browserStop(baseUrl: string): Promise<void> {
export async function browserResetProfile(
baseUrl: string,
opts?: { profile?: string },
): Promise<BrowserResetProfileResult> {
const q = buildProfileQuery(opts?.profile);
return await fetchBrowserJson<BrowserResetProfileResult>(
`${baseUrl}/reset-profile`,
`${baseUrl}/reset-profile${q}`,
{
method: "POST",
timeoutMs: 20000,
@@ -99,9 +140,60 @@ export async function browserResetProfile(
);
}
export async function browserTabs(baseUrl: string): Promise<BrowserTab[]> {
export type BrowserCreateProfileResult = {
ok: true;
profile: string;
cdpPort: number;
cdpUrl: string;
color: string;
isRemote: boolean;
};
export async function browserCreateProfile(
baseUrl: string,
opts: { name: string; color?: string; cdpUrl?: string },
): Promise<BrowserCreateProfileResult> {
return await fetchBrowserJson<BrowserCreateProfileResult>(
`${baseUrl}/profiles/create`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: opts.name,
color: opts.color,
cdpUrl: opts.cdpUrl,
}),
timeoutMs: 10000,
},
);
}
export type BrowserDeleteProfileResult = {
ok: true;
profile: string;
deleted: boolean;
};
export async function browserDeleteProfile(
baseUrl: string,
profile: string,
): Promise<BrowserDeleteProfileResult> {
return await fetchBrowserJson<BrowserDeleteProfileResult>(
`${baseUrl}/profiles/${encodeURIComponent(profile)}`,
{
method: "DELETE",
timeoutMs: 20000,
},
);
}
export async function browserTabs(
baseUrl: string,
opts?: { profile?: string },
): Promise<BrowserTab[]> {
const q = buildProfileQuery(opts?.profile);
const res = await fetchBrowserJson<{ running: boolean; tabs: BrowserTab[] }>(
`${baseUrl}/tabs`,
`${baseUrl}/tabs${q}`,
{ timeoutMs: 3000 },
);
return res.tabs ?? [];
@@ -110,8 +202,10 @@ export async function browserTabs(baseUrl: string): Promise<BrowserTab[]> {
export async function browserOpenTab(
baseUrl: string,
url: string,
opts?: { profile?: string },
): Promise<BrowserTab> {
return await fetchBrowserJson<BrowserTab>(`${baseUrl}/tabs/open`, {
const q = buildProfileQuery(opts?.profile);
return await fetchBrowserJson<BrowserTab>(`${baseUrl}/tabs/open${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url }),
@@ -122,8 +216,10 @@ export async function browserOpenTab(
export async function browserFocusTab(
baseUrl: string,
targetId: string,
opts?: { profile?: string },
): Promise<void> {
await fetchBrowserJson(`${baseUrl}/tabs/focus`, {
const q = buildProfileQuery(opts?.profile);
await fetchBrowserJson(`${baseUrl}/tabs/focus${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId }),
@@ -134,11 +230,16 @@ export async function browserFocusTab(
export async function browserCloseTab(
baseUrl: string,
targetId: string,
opts?: { profile?: string },
): Promise<void> {
await fetchBrowserJson(`${baseUrl}/tabs/${encodeURIComponent(targetId)}`, {
method: "DELETE",
timeoutMs: 5000,
});
const q = buildProfileQuery(opts?.profile);
await fetchBrowserJson(
`${baseUrl}/tabs/${encodeURIComponent(targetId)}${q}`,
{
method: "DELETE",
timeoutMs: 5000,
},
);
}
export async function browserSnapshot(
@@ -147,12 +248,14 @@ export async function browserSnapshot(
format: "aria" | "ai";
targetId?: string;
limit?: number;
profile?: string;
},
): Promise<SnapshotResult> {
const q = new URLSearchParams();
q.set("format", opts.format);
if (opts.targetId) q.set("targetId", opts.targetId);
if (typeof opts.limit === "number") q.set("limit", String(opts.limit));
if (opts.profile) q.set("profile", opts.profile);
return await fetchBrowserJson<SnapshotResult>(
`${baseUrl}/snapshot?${q.toString()}`,
{
+40 -9
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
resolveBrowserConfig,
resolveProfile,
shouldStartLocalBrowserServer,
} from "./config.js";
@@ -9,11 +10,13 @@ describe("browser config", () => {
const resolved = resolveBrowserConfig(undefined);
expect(resolved.enabled).toBe(true);
expect(resolved.controlPort).toBe(18791);
expect(resolved.cdpPort).toBe(18792);
expect(resolved.cdpUrl).toBe("http://127.0.0.1:18792");
expect(resolved.controlHost).toBe("127.0.0.1");
expect(resolved.color).toBe("#FF4500");
expect(shouldStartLocalBrowserServer(resolved)).toBe(true);
const profile = resolveProfile(resolved, resolved.defaultProfile);
expect(profile?.cdpPort).toBe(18800);
expect(profile?.cdpUrl).toBe("http://127.0.0.1:18800");
expect(profile?.cdpIsLoopback).toBe(true);
});
it("normalizes hex colors", () => {
@@ -39,23 +42,51 @@ describe("browser config", () => {
expect(shouldStartLocalBrowserServer(resolved)).toBe(false);
});
it("derives CDP port as control port + 1", () => {
it("derives CDP host/protocol from control url when cdpUrl is unset", () => {
const resolved = resolveBrowserConfig({
controlUrl: "http://127.0.0.1:19000",
});
expect(resolved.controlPort).toBe(19000);
expect(resolved.cdpPort).toBe(19001);
expect(resolved.cdpUrl).toBe("http://127.0.0.1:19001");
expect(resolved.cdpHost).toBe("127.0.0.1");
expect(resolved.cdpProtocol).toBe("http");
});
it("supports explicit CDP URLs", () => {
it("supports explicit CDP URLs for the default profile", () => {
const resolved = resolveBrowserConfig({
controlUrl: "http://127.0.0.1:18791",
cdpUrl: "http://example.com:9222",
});
expect(resolved.cdpPort).toBe(9222);
expect(resolved.cdpUrl).toBe("http://example.com:9222");
expect(resolved.cdpIsLoopback).toBe(false);
const profile = resolveProfile(resolved, resolved.defaultProfile);
expect(profile?.cdpPort).toBe(9222);
expect(profile?.cdpUrl).toBe("http://example.com:9222");
expect(profile?.cdpIsLoopback).toBe(false);
});
it("uses profile cdpUrl when provided", () => {
const resolved = resolveBrowserConfig({
controlUrl: "http://127.0.0.1:18791",
profiles: {
remote: { cdpUrl: "http://10.0.0.42:9222", color: "#0066CC" },
},
});
const remote = resolveProfile(resolved, "remote");
expect(remote?.cdpUrl).toBe("http://10.0.0.42:9222");
expect(remote?.cdpHost).toBe("10.0.0.42");
expect(remote?.cdpIsLoopback).toBe(false);
});
it("uses base protocol for profiles with only cdpPort", () => {
const resolved = resolveBrowserConfig({
controlUrl: "http://127.0.0.1:18791",
cdpUrl: "https://example.com:9443",
profiles: {
work: { cdpPort: 18801, color: "#0066CC" },
},
});
const work = resolveProfile(resolved, "work");
expect(work?.cdpUrl).toBe("https://example.com:18801");
});
it("rejects unsupported protocols", () => {
+90 -8
View File
@@ -1,24 +1,36 @@
import type { BrowserConfig } from "../config/config.js";
import type { BrowserConfig, BrowserProfileConfig } from "../config/config.js";
import {
DEFAULT_CLAWD_BROWSER_COLOR,
DEFAULT_CLAWD_BROWSER_CONTROL_URL,
DEFAULT_CLAWD_BROWSER_ENABLED,
DEFAULT_CLAWD_BROWSER_PROFILE_NAME,
} from "./constants.js";
import { CDP_PORT_RANGE_START } from "./profiles.js";
export type ResolvedBrowserConfig = {
enabled: boolean;
controlUrl: string;
controlHost: string;
controlPort: number;
cdpUrl: string;
cdpProtocol: "http" | "https";
cdpHost: string;
cdpPort: number;
cdpIsLoopback: boolean;
color: string;
executablePath?: string;
headless: boolean;
noSandbox: boolean;
attachOnly: boolean;
defaultProfile: string;
profiles: Record<string, BrowserProfileConfig>;
};
export type ResolvedBrowserProfile = {
name: string;
cdpPort: number;
cdpUrl: string;
cdpHost: string;
cdpIsLoopback: boolean;
color: string;
};
function isLoopbackHost(host: string) {
@@ -42,7 +54,7 @@ function normalizeHexColor(raw: string | undefined) {
return normalized.toUpperCase();
}
function parseHttpUrl(raw: string, label: string) {
export function parseHttpUrl(raw: string, label: string) {
const trimmed = raw.trim();
const parsed = new URL(trimmed);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
@@ -69,6 +81,24 @@ function parseHttpUrl(raw: string, label: string) {
};
}
/**
* Ensure the default "clawd" profile exists in the profiles map.
* Auto-creates it with the legacy CDP port (from browser.cdpUrl) or first port if missing.
*/
function ensureDefaultProfile(
profiles: Record<string, BrowserProfileConfig> | undefined,
defaultColor: string,
legacyCdpPort?: number,
): Record<string, BrowserProfileConfig> {
const result = { ...profiles };
if (!result[DEFAULT_CLAWD_BROWSER_PROFILE_NAME]) {
result[DEFAULT_CLAWD_BROWSER_PROFILE_NAME] = {
cdpPort: legacyCdpPort ?? CDP_PORT_RANGE_START,
color: defaultColor,
};
}
return result;
}
export function resolveBrowserConfig(
cfg: BrowserConfig | undefined,
): ResolvedBrowserConfig {
@@ -78,6 +108,7 @@ export function resolveBrowserConfig(
"browser.controlUrl",
);
const controlPort = controlInfo.port;
const defaultColor = normalizeHexColor(cfg?.color);
const rawCdpUrl = (cfg?.cdpUrl ?? "").trim();
let cdpInfo:
@@ -105,26 +136,77 @@ export function resolveBrowserConfig(
};
}
const cdpPort = cdpInfo.port;
const headless = cfg?.headless === true;
const noSandbox = cfg?.noSandbox === true;
const attachOnly = cfg?.attachOnly === true;
const executablePath = cfg?.executablePath?.trim() || undefined;
const defaultProfile =
cfg?.defaultProfile ?? DEFAULT_CLAWD_BROWSER_PROFILE_NAME;
// Use legacy cdpUrl port for backward compatibility when no profiles configured
const legacyCdpPort = rawCdpUrl ? cdpInfo.port : undefined;
const profiles = ensureDefaultProfile(
cfg?.profiles,
defaultColor,
legacyCdpPort,
);
const cdpProtocol = cdpInfo.parsed.protocol === "https:" ? "https" : "http";
return {
enabled,
controlUrl: controlInfo.normalized,
controlHost: controlInfo.parsed.hostname,
controlPort,
cdpUrl: cdpInfo.normalized,
cdpProtocol,
cdpHost: cdpInfo.parsed.hostname,
cdpPort,
cdpIsLoopback: isLoopbackHost(cdpInfo.parsed.hostname),
color: normalizeHexColor(cfg?.color),
color: defaultColor,
executablePath,
headless,
noSandbox,
attachOnly,
defaultProfile,
profiles,
};
}
/**
* Resolve a profile by name from the config.
* Returns null if the profile doesn't exist.
*/
export function resolveProfile(
resolved: ResolvedBrowserConfig,
profileName: string,
): ResolvedBrowserProfile | null {
const profile = resolved.profiles[profileName];
if (!profile) return null;
const rawProfileUrl = profile.cdpUrl?.trim() ?? "";
let cdpHost = resolved.cdpHost;
let cdpPort = profile.cdpPort ?? 0;
let cdpUrl = "";
if (rawProfileUrl) {
const parsed = parseHttpUrl(
rawProfileUrl,
`browser.profiles.${profileName}.cdpUrl`,
);
cdpHost = parsed.parsed.hostname;
cdpPort = parsed.port;
cdpUrl = parsed.normalized;
} else if (cdpPort) {
cdpUrl = `${resolved.cdpProtocol}://${resolved.cdpHost}:${cdpPort}`;
} else {
throw new Error(`Profile "${profileName}" must define cdpPort or cdpUrl.`);
}
return {
name: profileName,
cdpPort,
cdpUrl,
cdpHost,
cdpIsLoopback: isLoopbackHost(cdpHost),
color: profile.color,
};
}
+154
View File
@@ -0,0 +1,154 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { resolveBrowserConfig } from "./config.js";
import { createBrowserProfilesService } from "./profiles-service.js";
import type {
BrowserRouteContext,
BrowserServerState,
} from "./server-context.js";
vi.mock("../config/config.js", () => ({
loadConfig: vi.fn(),
writeConfigFile: vi.fn(async () => {}),
}));
vi.mock("./trash.js", () => ({
movePathToTrash: vi.fn(async (targetPath: string) => targetPath),
}));
vi.mock("./chrome.js", () => ({
resolveClawdUserDataDir: vi.fn(() => "/tmp/clawd-test/clawd/user-data"),
}));
import { loadConfig, writeConfigFile } from "../config/config.js";
import { resolveClawdUserDataDir } from "./chrome.js";
import { movePathToTrash } from "./trash.js";
function createCtx(resolved: BrowserServerState["resolved"]) {
const state: BrowserServerState = {
server: null as unknown as BrowserServerState["server"],
port: 0,
resolved,
profiles: new Map(),
};
const ctx = {
state: () => state,
listProfiles: vi.fn(async () => []),
forProfile: vi.fn(() => ({
stopRunningBrowser: vi.fn(async () => ({ stopped: true })),
})),
} as unknown as BrowserRouteContext;
return { state, ctx };
}
describe("BrowserProfilesService", () => {
it("allocates next local port for new profiles", async () => {
const resolved = resolveBrowserConfig({
controlUrl: "http://127.0.0.1:18791",
});
const { ctx, state } = createCtx(resolved);
vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } });
const service = createBrowserProfilesService(ctx);
const result = await service.createProfile({ name: "work" });
expect(result.cdpPort).toBe(18801);
expect(result.isRemote).toBe(false);
expect(state.resolved.profiles.work?.cdpPort).toBe(18801);
expect(writeConfigFile).toHaveBeenCalled();
});
it("accepts per-profile cdpUrl for remote Chrome", async () => {
const resolved = resolveBrowserConfig({
controlUrl: "http://127.0.0.1:18791",
});
const { ctx } = createCtx(resolved);
vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } });
const service = createBrowserProfilesService(ctx);
const result = await service.createProfile({
name: "remote",
cdpUrl: "http://10.0.0.42:9222",
});
expect(result.cdpUrl).toBe("http://10.0.0.42:9222");
expect(result.cdpPort).toBe(9222);
expect(result.isRemote).toBe(true);
expect(writeConfigFile).toHaveBeenCalledWith(
expect.objectContaining({
browser: expect.objectContaining({
profiles: expect.objectContaining({
remote: expect.objectContaining({
cdpUrl: "http://10.0.0.42:9222",
}),
}),
}),
}),
);
});
it("deletes remote profiles without stopping or removing local data", async () => {
const resolved = resolveBrowserConfig({
controlUrl: "http://127.0.0.1:18791",
profiles: {
remote: { cdpUrl: "http://10.0.0.42:9222", color: "#0066CC" },
},
});
const { ctx } = createCtx(resolved);
vi.mocked(loadConfig).mockReturnValue({
browser: {
defaultProfile: "clawd",
profiles: {
clawd: { cdpPort: 18800, color: "#FF4500" },
remote: { cdpUrl: "http://10.0.0.42:9222", color: "#0066CC" },
},
},
});
const service = createBrowserProfilesService(ctx);
const result = await service.deleteProfile("remote");
expect(result.deleted).toBe(false);
expect(ctx.forProfile).not.toHaveBeenCalled();
expect(movePathToTrash).not.toHaveBeenCalled();
});
it("deletes local profiles and moves data to Trash", async () => {
const resolved = resolveBrowserConfig({
controlUrl: "http://127.0.0.1:18791",
profiles: {
work: { cdpPort: 18801, color: "#0066CC" },
},
});
const { ctx } = createCtx(resolved);
vi.mocked(loadConfig).mockReturnValue({
browser: {
defaultProfile: "clawd",
profiles: {
clawd: { cdpPort: 18800, color: "#FF4500" },
work: { cdpPort: 18801, color: "#0066CC" },
},
},
});
const tempDir = fs.mkdtempSync(path.join("/tmp", "clawd-profile-"));
const userDataDir = path.join(tempDir, "work", "user-data");
fs.mkdirSync(path.dirname(userDataDir), { recursive: true });
vi.mocked(resolveClawdUserDataDir).mockReturnValue(userDataDir);
const service = createBrowserProfilesService(ctx);
const result = await service.deleteProfile("work");
expect(result.deleted).toBe(true);
expect(movePathToTrash).toHaveBeenCalledWith(path.dirname(userDataDir));
});
});
+181
View File
@@ -0,0 +1,181 @@
import fs from "node:fs";
import path from "node:path";
import type { BrowserProfileConfig, ClawdisConfig } from "../config/config.js";
import { loadConfig, writeConfigFile } from "../config/config.js";
import { resolveClawdUserDataDir } from "./chrome.js";
import { parseHttpUrl, resolveProfile } from "./config.js";
import {
allocateCdpPort,
allocateColor,
getUsedColors,
getUsedPorts,
isValidProfileName,
} from "./profiles.js";
import type { BrowserRouteContext, ProfileStatus } from "./server-context.js";
import { movePathToTrash } from "./trash.js";
export type CreateProfileParams = {
name: string;
color?: string;
cdpUrl?: string;
};
export type CreateProfileResult = {
ok: true;
profile: string;
cdpPort: number;
cdpUrl: string;
color: string;
isRemote: boolean;
};
export type DeleteProfileResult = {
ok: true;
profile: string;
deleted: boolean;
};
const HEX_COLOR_RE = /^#[0-9A-Fa-f]{6}$/;
export function createBrowserProfilesService(ctx: BrowserRouteContext) {
const listProfiles = async (): Promise<ProfileStatus[]> => {
return await ctx.listProfiles();
};
const createProfile = async (
params: CreateProfileParams,
): Promise<CreateProfileResult> => {
const name = params.name.trim();
const rawCdpUrl = params.cdpUrl?.trim() || undefined;
if (!isValidProfileName(name)) {
throw new Error(
"invalid profile name: use lowercase letters, numbers, and hyphens only",
);
}
const state = ctx.state();
const resolvedProfiles = state.resolved.profiles;
if (name in resolvedProfiles) {
throw new Error(`profile "${name}" already exists`);
}
const cfg = loadConfig();
const rawProfiles = cfg.browser?.profiles ?? {};
if (name in rawProfiles) {
throw new Error(`profile "${name}" already exists`);
}
const usedColors = getUsedColors(resolvedProfiles);
const profileColor =
params.color && HEX_COLOR_RE.test(params.color)
? params.color
: allocateColor(usedColors);
let profileConfig: BrowserProfileConfig;
if (rawCdpUrl) {
const parsed = parseHttpUrl(rawCdpUrl, "browser.profiles.cdpUrl");
profileConfig = { cdpUrl: parsed.normalized, color: profileColor };
} else {
const usedPorts = getUsedPorts(resolvedProfiles);
const cdpPort = allocateCdpPort(usedPorts);
if (cdpPort === null) {
throw new Error("no available CDP ports in range");
}
profileConfig = { cdpPort, color: profileColor };
}
const nextConfig: ClawdisConfig = {
...cfg,
browser: {
...cfg.browser,
profiles: {
...rawProfiles,
[name]: profileConfig,
},
},
};
await writeConfigFile(nextConfig);
state.resolved.profiles[name] = profileConfig;
const resolved = resolveProfile(state.resolved, name);
if (!resolved) {
throw new Error(`profile "${name}" not found after creation`);
}
return {
ok: true,
profile: name,
cdpPort: resolved.cdpPort,
cdpUrl: resolved.cdpUrl,
color: resolved.color,
isRemote: !resolved.cdpIsLoopback,
};
};
const deleteProfile = async (
nameRaw: string,
): Promise<DeleteProfileResult> => {
const name = nameRaw.trim();
if (!name) throw new Error("profile name is required");
if (!isValidProfileName(name)) {
throw new Error("invalid profile name");
}
const cfg = loadConfig();
const profiles = cfg.browser?.profiles ?? {};
if (!(name in profiles)) {
throw new Error(`profile "${name}" not found`);
}
const defaultProfile = cfg.browser?.defaultProfile ?? "clawd";
if (name === defaultProfile) {
throw new Error(
`cannot delete the default profile "${name}"; change browser.defaultProfile first`,
);
}
let deleted = false;
const state = ctx.state();
const resolved = resolveProfile(state.resolved, name);
if (resolved?.cdpIsLoopback) {
try {
await ctx.forProfile(name).stopRunningBrowser();
} catch {
// ignore
}
const userDataDir = resolveClawdUserDataDir(name);
const profileDir = path.dirname(userDataDir);
if (fs.existsSync(profileDir)) {
await movePathToTrash(profileDir);
deleted = true;
}
}
const { [name]: _removed, ...remainingProfiles } = profiles;
const nextConfig: ClawdisConfig = {
...cfg,
browser: {
...cfg.browser,
profiles: remainingProfiles,
},
};
await writeConfigFile(nextConfig);
delete state.resolved.profiles[name];
state.profiles.delete(name);
return { ok: true, profile: name, deleted };
};
return {
listProfiles,
createProfile,
deleteProfile,
};
}
+240
View File
@@ -0,0 +1,240 @@
import { describe, expect, it } from "vitest";
import {
allocateCdpPort,
allocateColor,
CDP_PORT_RANGE_END,
CDP_PORT_RANGE_START,
getUsedColors,
getUsedPorts,
isValidProfileName,
PROFILE_COLORS,
} from "./profiles.js";
describe("profile name validation", () => {
it("accepts valid lowercase names", () => {
expect(isValidProfileName("clawd")).toBe(true);
expect(isValidProfileName("work")).toBe(true);
expect(isValidProfileName("my-profile")).toBe(true);
expect(isValidProfileName("test123")).toBe(true);
expect(isValidProfileName("a")).toBe(true);
expect(isValidProfileName("a-b-c-1-2-3")).toBe(true);
expect(isValidProfileName("1test")).toBe(true);
});
it("rejects empty or missing names", () => {
expect(isValidProfileName("")).toBe(false);
// @ts-expect-error testing invalid input
expect(isValidProfileName(null)).toBe(false);
// @ts-expect-error testing invalid input
expect(isValidProfileName(undefined)).toBe(false);
});
it("rejects names that are too long", () => {
const longName = "a".repeat(65);
expect(isValidProfileName(longName)).toBe(false);
const maxName = "a".repeat(64);
expect(isValidProfileName(maxName)).toBe(true);
});
it("rejects uppercase letters", () => {
expect(isValidProfileName("MyProfile")).toBe(false);
expect(isValidProfileName("PROFILE")).toBe(false);
expect(isValidProfileName("Work")).toBe(false);
});
it("rejects spaces and special characters", () => {
expect(isValidProfileName("my profile")).toBe(false);
expect(isValidProfileName("my_profile")).toBe(false);
expect(isValidProfileName("my.profile")).toBe(false);
expect(isValidProfileName("my/profile")).toBe(false);
expect(isValidProfileName("my@profile")).toBe(false);
});
it("rejects names starting with hyphen", () => {
expect(isValidProfileName("-invalid")).toBe(false);
expect(isValidProfileName("--double")).toBe(false);
});
});
describe("port allocation", () => {
it("allocates first port when none used", () => {
const usedPorts = new Set<number>();
expect(allocateCdpPort(usedPorts)).toBe(CDP_PORT_RANGE_START);
});
it("skips used ports and returns next available", () => {
const usedPorts = new Set([CDP_PORT_RANGE_START, CDP_PORT_RANGE_START + 1]);
expect(allocateCdpPort(usedPorts)).toBe(CDP_PORT_RANGE_START + 2);
});
it("finds first gap in used ports", () => {
const usedPorts = new Set([
CDP_PORT_RANGE_START,
CDP_PORT_RANGE_START + 2, // gap at +1
]);
expect(allocateCdpPort(usedPorts)).toBe(CDP_PORT_RANGE_START + 1);
});
it("returns null when all ports are exhausted", () => {
const usedPorts = new Set<number>();
for (let port = CDP_PORT_RANGE_START; port <= CDP_PORT_RANGE_END; port++) {
usedPorts.add(port);
}
expect(allocateCdpPort(usedPorts)).toBeNull();
});
it("handles ports outside range in used set", () => {
const usedPorts = new Set([1, 2, 3, 50000]); // ports outside range
expect(allocateCdpPort(usedPorts)).toBe(CDP_PORT_RANGE_START);
});
});
describe("getUsedPorts", () => {
it("returns empty set for undefined profiles", () => {
expect(getUsedPorts(undefined)).toEqual(new Set());
});
it("returns empty set for empty profiles object", () => {
expect(getUsedPorts({})).toEqual(new Set());
});
it("extracts ports from profile configs", () => {
const profiles = {
clawd: { cdpPort: 18792 },
work: { cdpPort: 18793 },
personal: { cdpPort: 18795 },
};
const used = getUsedPorts(profiles);
expect(used).toEqual(new Set([18792, 18793, 18795]));
});
it("extracts ports from cdpUrl when cdpPort is missing", () => {
const profiles = {
remote: { cdpUrl: "http://10.0.0.42:9222" },
secure: { cdpUrl: "https://example.com:9443" },
};
const used = getUsedPorts(profiles);
expect(used).toEqual(new Set([9222, 9443]));
});
it("ignores invalid cdpUrl values", () => {
const profiles = {
bad: { cdpUrl: "notaurl" },
};
const used = getUsedPorts(profiles);
expect(used.size).toBe(0);
});
});
describe("port collision prevention", () => {
it("raw config vs resolved config - shows the data source difference", async () => {
// This demonstrates WHY the route handler must use resolved config
const { resolveBrowserConfig } = await import("./config.js");
// Fresh config with no profiles defined (like a new install)
const rawConfigProfiles = undefined;
const usedFromRaw = getUsedPorts(rawConfigProfiles);
// Raw config shows empty - no ports used
expect(usedFromRaw.size).toBe(0);
// But resolved config has implicit clawd at 18800
const resolved = resolveBrowserConfig({});
const usedFromResolved = getUsedPorts(resolved.profiles);
expect(usedFromResolved.has(CDP_PORT_RANGE_START)).toBe(true);
});
it("create-profile must use resolved config to avoid port collision", async () => {
// The route handler must use state.resolved.profiles, not raw config
const { resolveBrowserConfig } = await import("./config.js");
// Simulate what happens with raw config (empty) vs resolved config
const rawConfig = { browser: {} }; // Fresh config, no profiles
const buggyUsedPorts = getUsedPorts(rawConfig.browser?.profiles);
const buggyAllocatedPort = allocateCdpPort(buggyUsedPorts);
// Raw config: first allocation gets 18800
expect(buggyAllocatedPort).toBe(CDP_PORT_RANGE_START);
// Resolved config: includes implicit clawd at 18800
const resolved = resolveBrowserConfig(rawConfig.browser);
const fixedUsedPorts = getUsedPorts(resolved.profiles);
const fixedAllocatedPort = allocateCdpPort(fixedUsedPorts);
// Resolved: first NEW profile gets 18801, avoiding collision
expect(fixedAllocatedPort).toBe(CDP_PORT_RANGE_START + 1);
});
});
describe("color allocation", () => {
it("allocates first color when none used", () => {
const usedColors = new Set<string>();
expect(allocateColor(usedColors)).toBe(PROFILE_COLORS[0]);
});
it("allocates next unused color from palette", () => {
// biome-ignore lint/style/noNonNullAssertion: Test file with known array
const usedColors = new Set([PROFILE_COLORS[0]!.toUpperCase()]);
expect(allocateColor(usedColors)).toBe(PROFILE_COLORS[1]);
});
it("skips multiple used colors", () => {
const usedColors = new Set([
// biome-ignore lint/style/noNonNullAssertion: Test file with known array
PROFILE_COLORS[0]!.toUpperCase(),
// biome-ignore lint/style/noNonNullAssertion: Test file with known array
PROFILE_COLORS[1]!.toUpperCase(),
// biome-ignore lint/style/noNonNullAssertion: Test file with known array
PROFILE_COLORS[2]!.toUpperCase(),
]);
expect(allocateColor(usedColors)).toBe(PROFILE_COLORS[3]);
});
it("handles case-insensitive color matching", () => {
const usedColors = new Set(["#ff4500"]); // lowercase
// Should still skip this color (case-insensitive)
// Note: allocateColor compares against uppercase, so lowercase won't match
// This tests the current behavior
expect(allocateColor(usedColors)).toBe(PROFILE_COLORS[0]); // returns first since lowercase doesn't match
});
it("cycles when all colors are used", () => {
const usedColors = new Set(PROFILE_COLORS.map((c) => c.toUpperCase()));
// Should cycle based on count
const result = allocateColor(usedColors);
expect(PROFILE_COLORS).toContain(result);
});
it("cycles based on count when palette exhausted", () => {
// Add all colors plus some extras
const usedColors = new Set([
...PROFILE_COLORS.map((c) => c.toUpperCase()),
"#AAAAAA",
"#BBBBBB",
]);
const result = allocateColor(usedColors);
// Index should be (10 + 2) % 10 = 2
expect(result).toBe(PROFILE_COLORS[2]);
});
});
describe("getUsedColors", () => {
it("returns empty set for undefined profiles", () => {
expect(getUsedColors(undefined)).toEqual(new Set());
});
it("returns empty set for empty profiles object", () => {
expect(getUsedColors({})).toEqual(new Set());
});
it("extracts and uppercases colors from profile configs", () => {
const profiles = {
clawd: { color: "#ff4500" },
work: { color: "#0066CC" },
};
const used = getUsedColors(profiles);
expect(used).toEqual(new Set(["#FF4500", "#0066CC"]));
});
});
+92
View File
@@ -0,0 +1,92 @@
/**
* CDP port allocation for browser profiles.
*
* Port range: 18800-18899 (100 profiles max)
* Ports are allocated once at profile creation and persisted in config.
*
* Reserved ports (do not use for CDP):
* 18789 - Gateway WebSocket
* 18790 - Bridge
* 18791 - Browser control server
* 18792-18799 - Reserved for future one-off services (canvas at 18793)
*/
export const CDP_PORT_RANGE_START = 18800;
export const CDP_PORT_RANGE_END = 18899;
export const PROFILE_NAME_REGEX = /^[a-z0-9][a-z0-9-]*$/;
export function isValidProfileName(name: string): boolean {
if (!name || name.length > 64) return false;
return PROFILE_NAME_REGEX.test(name);
}
export function allocateCdpPort(usedPorts: Set<number>): number | null {
for (let port = CDP_PORT_RANGE_START; port <= CDP_PORT_RANGE_END; port++) {
if (!usedPorts.has(port)) return port;
}
return null;
}
export function getUsedPorts(
profiles: Record<string, { cdpPort?: number; cdpUrl?: string }> | undefined,
): Set<number> {
if (!profiles) return new Set();
const used = new Set<number>();
for (const profile of Object.values(profiles)) {
if (typeof profile.cdpPort === "number") {
used.add(profile.cdpPort);
continue;
}
const rawUrl = profile.cdpUrl?.trim();
if (!rawUrl) continue;
try {
const parsed = new URL(rawUrl);
const port =
parsed.port && Number.parseInt(parsed.port, 10) > 0
? Number.parseInt(parsed.port, 10)
: parsed.protocol === "https:"
? 443
: 80;
if (!Number.isNaN(port) && port > 0 && port <= 65535) {
used.add(port);
}
} catch {
// ignore invalid URLs
}
}
return used;
}
export const PROFILE_COLORS = [
"#FF4500", // Orange-red (clawd default)
"#0066CC", // Blue
"#00AA00", // Green
"#9933FF", // Purple
"#FF6699", // Pink
"#00CCCC", // Cyan
"#FF9900", // Orange
"#6666FF", // Indigo
"#CC3366", // Magenta
"#339966", // Teal
];
export function allocateColor(usedColors: Set<string>): string {
// Find first unused color from palette
for (const color of PROFILE_COLORS) {
if (!usedColors.has(color.toUpperCase())) {
return color;
}
}
// All colors used, cycle based on count
const index = usedColors.size % PROFILE_COLORS.length;
// biome-ignore lint/style/noNonNullAssertion: Array is non-empty constant
return PROFILE_COLORS[index] ?? PROFILE_COLORS[0]!;
}
export function getUsedColors(
profiles: Record<string, { color: string }> | undefined,
): Set<string> {
if (!profiles) return new Set();
return new Set(Object.values(profiles).map((p) => p.color.toUpperCase()));
}
+49 -19
View File
@@ -10,8 +10,9 @@ import {
DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE,
normalizeBrowserScreenshot,
} from "../screenshot.js";
import type { BrowserRouteContext } from "../server-context.js";
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
import {
getProfileContext,
jsonError,
toBoolean,
toNumber,
@@ -61,6 +62,19 @@ function handleRouteError(
jsonError(res, 500, String(err));
}
function resolveProfileContext(
req: express.Request,
res: express.Response,
ctx: BrowserRouteContext,
): ProfileContext | null {
const profileCtx = getProfileContext(req, ctx);
if ("error" in profileCtx) {
jsonError(res, profileCtx.status, profileCtx.error);
return null;
}
return profileCtx;
}
function parseClickButton(raw: string): ClickButton | undefined {
if (raw === "left" || raw === "right" || raw === "middle") return raw;
return undefined;
@@ -100,16 +114,18 @@ export function registerBrowserAgentRoutes(
ctx: BrowserRouteContext,
) {
app.post("/navigate", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
const body = readBody(req);
const url = toStringOrEmpty(body.url);
const targetId = toStringOrEmpty(body.targetId) || undefined;
if (!url) return jsonError(res, 400, "url is required");
try {
const tab = await ctx.ensureTabAvailable(targetId);
const tab = await profileCtx.ensureTabAvailable(targetId);
const pw = await requirePwAi(res, "navigate");
if (!pw) return;
const result = await pw.navigateViaPlaywright({
cdpUrl: ctx.state().resolved.cdpUrl,
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
url,
});
@@ -120,6 +136,8 @@ export function registerBrowserAgentRoutes(
});
app.post("/act", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
const body = readBody(req);
const kind = toStringOrEmpty(body.kind) as ActKind;
const targetId = toStringOrEmpty(body.targetId) || undefined;
@@ -144,8 +162,8 @@ export function registerBrowserAgentRoutes(
}
try {
const tab = await ctx.ensureTabAvailable(targetId);
const cdpUrl = ctx.state().resolved.cdpUrl;
const tab = await profileCtx.ensureTabAvailable(targetId);
const cdpUrl = profileCtx.profile.cdpUrl;
const pw = await requirePwAi(res, `act:${kind}`);
if (!pw) return;
@@ -336,6 +354,8 @@ export function registerBrowserAgentRoutes(
});
app.post("/hooks/file-chooser", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || undefined;
const ref = toStringOrEmpty(body.ref) || undefined;
@@ -345,7 +365,7 @@ export function registerBrowserAgentRoutes(
const timeoutMs = toNumber(body.timeoutMs);
if (!paths.length) return jsonError(res, 400, "paths are required");
try {
const tab = await ctx.ensureTabAvailable(targetId);
const tab = await profileCtx.ensureTabAvailable(targetId);
const pw = await requirePwAi(res, "file chooser hook");
if (!pw) return;
if (inputRef || element) {
@@ -357,7 +377,7 @@ export function registerBrowserAgentRoutes(
);
}
await pw.setInputFilesViaPlaywright({
cdpUrl: ctx.state().resolved.cdpUrl,
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
inputRef,
element,
@@ -365,14 +385,14 @@ export function registerBrowserAgentRoutes(
});
} else {
await pw.armFileUploadViaPlaywright({
cdpUrl: ctx.state().resolved.cdpUrl,
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
paths,
timeoutMs: timeoutMs ?? undefined,
});
if (ref) {
await pw.clickViaPlaywright({
cdpUrl: ctx.state().resolved.cdpUrl,
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
ref,
});
@@ -385,6 +405,8 @@ export function registerBrowserAgentRoutes(
});
app.post("/hooks/dialog", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || undefined;
const accept = toBoolean(body.accept);
@@ -392,11 +414,11 @@ export function registerBrowserAgentRoutes(
const timeoutMs = toNumber(body.timeoutMs);
if (accept === undefined) return jsonError(res, 400, "accept is required");
try {
const tab = await ctx.ensureTabAvailable(targetId);
const tab = await profileCtx.ensureTabAvailable(targetId);
const pw = await requirePwAi(res, "dialog hook");
if (!pw) return;
await pw.armDialogViaPlaywright({
cdpUrl: ctx.state().resolved.cdpUrl,
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
accept,
promptText,
@@ -409,16 +431,18 @@ export function registerBrowserAgentRoutes(
});
app.get("/console", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
const targetId =
typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
const level = typeof req.query.level === "string" ? req.query.level : "";
try {
const tab = await ctx.ensureTabAvailable(targetId || undefined);
const tab = await profileCtx.ensureTabAvailable(targetId || undefined);
const pw = await requirePwAi(res, "console messages");
if (!pw) return;
const messages = await pw.getConsoleMessagesViaPlaywright({
cdpUrl: ctx.state().resolved.cdpUrl,
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
level: level.trim() || undefined,
});
@@ -429,14 +453,16 @@ export function registerBrowserAgentRoutes(
});
app.post("/pdf", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || undefined;
try {
const tab = await ctx.ensureTabAvailable(targetId);
const tab = await profileCtx.ensureTabAvailable(targetId);
const pw = await requirePwAi(res, "pdf");
if (!pw) return;
const pdf = await pw.pdfViaPlaywright({
cdpUrl: ctx.state().resolved.cdpUrl,
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
});
await ensureMediaDir();
@@ -458,6 +484,8 @@ export function registerBrowserAgentRoutes(
});
app.post("/screenshot", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || undefined;
const fullPage = toBoolean(body.fullPage) ?? false;
@@ -474,13 +502,13 @@ export function registerBrowserAgentRoutes(
}
try {
const tab = await ctx.ensureTabAvailable(targetId);
const tab = await profileCtx.ensureTabAvailable(targetId);
let buffer: Buffer;
if (ref || element) {
const pw = await requirePwAi(res, "element/ref screenshot");
if (!pw) return;
const snap = await pw.takeScreenshotViaPlaywright({
cdpUrl: ctx.state().resolved.cdpUrl,
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
ref,
element,
@@ -520,6 +548,8 @@ export function registerBrowserAgentRoutes(
});
app.get("/snapshot", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
const targetId =
typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
const format =
@@ -534,12 +564,12 @@ export function registerBrowserAgentRoutes(
typeof req.query.limit === "string" ? Number(req.query.limit) : undefined;
try {
const tab = await ctx.ensureTabAvailable(targetId || undefined);
const tab = await profileCtx.ensureTabAvailable(targetId || undefined);
if (format === "ai") {
const pw = await requirePwAi(res, "ai snapshot");
if (!pw) return;
const snap = await pw.snapshotAiViaPlaywright({
cdpUrl: ctx.state().resolved.cdpUrl,
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
});
return res.json({
+121 -19
View File
@@ -1,13 +1,26 @@
import type express from "express";
import { createBrowserProfilesService } from "../profiles-service.js";
import type { BrowserRouteContext } from "../server-context.js";
import { jsonError } from "./utils.js";
import { getProfileContext, jsonError, toStringOrEmpty } from "./utils.js";
export function registerBrowserBasicRoutes(
app: express.Express,
ctx: BrowserRouteContext,
) {
app.get("/", async (_req, res) => {
// List all profiles with their status
app.get("/profiles", async (_req, res) => {
try {
const service = createBrowserProfilesService(ctx);
const profiles = await service.listProfiles();
res.json({ profiles });
} catch (err) {
jsonError(res, 500, String(err));
}
});
// Get status (profile-aware)
app.get("/", async (req, res) => {
let current: ReturnType<typeof ctx.state>;
try {
current = ctx.state();
@@ -15,22 +28,31 @@ export function registerBrowserBasicRoutes(
return jsonError(res, 503, "browser server not started");
}
const profileCtx = getProfileContext(req, ctx);
if ("error" in profileCtx) {
return jsonError(res, profileCtx.status, profileCtx.error);
}
const [cdpHttp, cdpReady] = await Promise.all([
ctx.isHttpReachable(300),
ctx.isReachable(600),
profileCtx.isHttpReachable(300),
profileCtx.isReachable(600),
]);
const profileState = current.profiles.get(profileCtx.profile.name);
res.json({
enabled: current.resolved.enabled,
controlUrl: current.resolved.controlUrl,
profile: profileCtx.profile.name,
running: cdpReady,
cdpReady,
cdpHttp,
pid: current.running?.pid ?? null,
cdpPort: current.cdpPort,
cdpUrl: current.resolved.cdpUrl,
chosenBrowser: current.running?.exe.kind ?? null,
userDataDir: current.running?.userDataDir ?? null,
color: current.resolved.color,
pid: profileState?.running?.pid ?? null,
cdpPort: profileCtx.profile.cdpPort,
cdpUrl: profileCtx.profile.cdpUrl,
chosenBrowser: profileState?.running?.exe.kind ?? null,
userDataDir: profileState?.running?.userDataDir ?? null,
color: profileCtx.profile.color,
headless: current.resolved.headless,
noSandbox: current.resolved.noSandbox,
executablePath: current.resolved.executablePath ?? null,
@@ -38,30 +60,110 @@ export function registerBrowserBasicRoutes(
});
});
app.post("/start", async (_req, res) => {
// Start browser (profile-aware)
app.post("/start", async (req, res) => {
const profileCtx = getProfileContext(req, ctx);
if ("error" in profileCtx) {
return jsonError(res, profileCtx.status, profileCtx.error);
}
try {
await ctx.ensureBrowserAvailable();
res.json({ ok: true });
await profileCtx.ensureBrowserAvailable();
res.json({ ok: true, profile: profileCtx.profile.name });
} catch (err) {
jsonError(res, 500, String(err));
}
});
app.post("/stop", async (_req, res) => {
// Stop browser (profile-aware)
app.post("/stop", async (req, res) => {
const profileCtx = getProfileContext(req, ctx);
if ("error" in profileCtx) {
return jsonError(res, profileCtx.status, profileCtx.error);
}
try {
const result = await ctx.stopRunningBrowser();
res.json({ ok: true, stopped: result.stopped });
const result = await profileCtx.stopRunningBrowser();
res.json({
ok: true,
stopped: result.stopped,
profile: profileCtx.profile.name,
});
} catch (err) {
jsonError(res, 500, String(err));
}
});
app.post("/reset-profile", async (_req, res) => {
// Reset profile (profile-aware)
app.post("/reset-profile", async (req, res) => {
const profileCtx = getProfileContext(req, ctx);
if ("error" in profileCtx) {
return jsonError(res, profileCtx.status, profileCtx.error);
}
try {
const result = await ctx.resetProfile();
res.json({ ok: true, ...result });
const result = await profileCtx.resetProfile();
res.json({ ok: true, profile: profileCtx.profile.name, ...result });
} catch (err) {
jsonError(res, 500, String(err));
}
});
// Create a new profile
app.post("/profiles/create", async (req, res) => {
const name = toStringOrEmpty((req.body as { name?: unknown })?.name);
const color = toStringOrEmpty((req.body as { color?: unknown })?.color);
const cdpUrl = toStringOrEmpty((req.body as { cdpUrl?: unknown })?.cdpUrl);
if (!name) return jsonError(res, 400, "name is required");
try {
const service = createBrowserProfilesService(ctx);
const result = await service.createProfile({
name,
color: color || undefined,
cdpUrl: cdpUrl || undefined,
});
res.json(result);
} catch (err) {
const msg = String(err);
if (msg.includes("already exists")) {
return jsonError(res, 409, msg);
}
if (msg.includes("invalid profile name")) {
return jsonError(res, 400, msg);
}
if (msg.includes("no available CDP ports")) {
return jsonError(res, 507, msg);
}
if (msg.includes("cdpUrl")) {
return jsonError(res, 400, msg);
}
jsonError(res, 500, msg);
}
});
// Delete a profile
app.delete("/profiles/:name", async (req, res) => {
const name = toStringOrEmpty(req.params.name);
if (!name) return jsonError(res, 400, "profile name is required");
try {
const service = createBrowserProfilesService(ctx);
const result = await service.deleteProfile(name);
res.json(result);
} catch (err) {
const msg = String(err);
if (msg.includes("invalid profile name")) {
return jsonError(res, 400, msg);
}
if (msg.includes("default profile")) {
return jsonError(res, 400, msg);
}
if (msg.includes("not found")) {
return jsonError(res, 404, msg);
}
jsonError(res, 500, msg);
}
});
}
+32
View File
@@ -1,5 +1,37 @@
import type express from "express";
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
/**
* Extract profile name from query string or body and get profile context.
* Query string takes precedence over body for consistency with GET routes.
*/
export function getProfileContext(
req: express.Request,
ctx: BrowserRouteContext,
): ProfileContext | { error: string; status: number } {
let profileName: string | undefined;
// Check query string first (works for GET and POST)
if (typeof req.query.profile === "string") {
profileName = req.query.profile.trim() || undefined;
}
// Fall back to body for POST requests
if (!profileName && req.body && typeof req.body === "object") {
const body = req.body as Record<string, unknown>;
if (typeof body.profile === "string") {
profileName = body.profile.trim() || undefined;
}
}
try {
return ctx.forProfile(profileName);
} catch (err) {
return { error: String(err), status: 404 };
}
}
export function jsonError(
res: express.Response,
status: number,
+249 -113
View File
@@ -1,9 +1,6 @@
import fs from "node:fs";
import type { Server } from "node:http";
import os from "node:os";
import path from "node:path";
import { runExec } from "../process/exec.js";
import { createTargetViaCdp, normalizeCdpWsUrl } from "./cdp.js";
import {
isChromeCdpReady,
@@ -13,27 +10,37 @@ import {
resolveClawdUserDataDir,
stopClawdChrome,
} from "./chrome.js";
import type { ResolvedBrowserConfig } from "./config.js";
import type { BrowserTab } from "./client.js";
import type {
ResolvedBrowserConfig,
ResolvedBrowserProfile,
} from "./config.js";
import { resolveProfile } from "./config.js";
import { resolveTargetIdFromTabs } from "./target-id.js";
import { movePathToTrash } from "./trash.js";
export type BrowserTab = {
targetId: string;
title: string;
url: string;
wsUrl?: string;
type?: string;
export type { BrowserTab };
/**
* Runtime state for a single profile's Chrome instance.
*/
export type ProfileRuntimeState = {
profile: ResolvedBrowserProfile;
running: RunningChrome | null;
};
export type BrowserServerState = {
server: Server;
port: number;
cdpPort: number;
running: RunningChrome | null;
resolved: ResolvedBrowserConfig;
profiles: Map<string, ProfileRuntimeState>;
};
export type BrowserRouteContext = {
state: () => BrowserServerState;
forProfile: (profileName?: string) => ProfileContext;
listProfiles: () => Promise<ProfileStatus[]>;
// Legacy methods delegate to default profile for backward compatibility
ensureBrowserAvailable: () => Promise<void>;
ensureTabAvailable: (targetId?: string) => Promise<BrowserTab>;
isHttpReachable: (timeoutMs?: number) => Promise<boolean>;
@@ -51,11 +58,50 @@ export type BrowserRouteContext = {
mapTabError: (err: unknown) => { status: number; message: string } | null;
};
export type ProfileContext = {
profile: ResolvedBrowserProfile;
ensureBrowserAvailable: () => Promise<void>;
ensureTabAvailable: (targetId?: string) => Promise<BrowserTab>;
isHttpReachable: (timeoutMs?: number) => Promise<boolean>;
isReachable: (timeoutMs?: number) => Promise<boolean>;
listTabs: () => Promise<BrowserTab[]>;
openTab: (url: string) => Promise<BrowserTab>;
focusTab: (targetId: string) => Promise<void>;
closeTab: (targetId: string) => Promise<void>;
stopRunningBrowser: () => Promise<{ stopped: boolean }>;
resetProfile: () => Promise<{ moved: boolean; from: string; to?: string }>;
};
export type ProfileStatus = {
name: string;
cdpPort: number;
cdpUrl: string;
color: string;
running: boolean;
tabCount: number;
isDefault: boolean;
isRemote: boolean;
};
type ContextOptions = {
getState: () => BrowserServerState | null;
setRunning: (running: RunningChrome | null) => void;
};
/**
* Normalize a CDP WebSocket URL to use the correct base URL.
*/
function normalizeWsUrl(
raw: string | undefined,
cdpBaseUrl: string,
): string | undefined {
if (!raw) return undefined;
try {
return normalizeCdpWsUrl(raw, cdpBaseUrl);
} catch {
return raw;
}
}
async function fetchJson<T>(
url: string,
timeoutMs = 1500,
@@ -87,26 +133,35 @@ async function fetchOk(
}
}
export function createBrowserRouteContext(
/**
* Create a profile-scoped context for browser operations.
*/
function createProfileContext(
opts: ContextOptions,
): BrowserRouteContext {
profile: ResolvedBrowserProfile,
): ProfileContext {
const state = () => {
const current = opts.getState();
if (!current) throw new Error("Browser server not started");
return current;
};
const listTabs = async (): Promise<BrowserTab[]> => {
const getProfileState = (): ProfileRuntimeState => {
const current = state();
const base = current.resolved.cdpUrl;
const normalizeWsUrl = (raw?: string) => {
if (!raw) return undefined;
try {
return normalizeCdpWsUrl(raw, base);
} catch {
return raw;
}
};
let profileState = current.profiles.get(profile.name);
if (!profileState) {
profileState = { profile, running: null };
current.profiles.set(profile.name, profileState);
}
return profileState;
};
const setProfileRunning = (running: RunningChrome | null) => {
const profileState = getProfileState();
profileState.running = running;
};
const listTabs = async (): Promise<BrowserTab[]> => {
const raw = await fetchJson<
Array<{
id?: string;
@@ -115,22 +170,21 @@ export function createBrowserRouteContext(
webSocketDebuggerUrl?: string;
type?: string;
}>
>(`${base.replace(/\/$/, "")}/json/list`);
>(`${profile.cdpUrl.replace(/\/$/, "")}/json/list`);
return raw
.map((t) => ({
targetId: t.id ?? "",
title: t.title ?? "",
url: t.url ?? "",
wsUrl: normalizeWsUrl(t.webSocketDebuggerUrl),
wsUrl: normalizeWsUrl(t.webSocketDebuggerUrl, profile.cdpUrl),
type: t.type,
}))
.filter((t) => Boolean(t.targetId));
};
const openTab = async (url: string): Promise<BrowserTab> => {
const current = state();
const createdViaCdp = await createTargetViaCdp({
cdpUrl: current.resolved.cdpUrl,
cdpUrl: profile.cdpUrl,
url,
})
.then((r) => r.targetId)
@@ -148,7 +202,6 @@ export function createBrowserRouteContext(
}
const encoded = encodeURIComponent(url);
type CdpTarget = {
id?: string;
title?: string;
@@ -157,15 +210,7 @@ export function createBrowserRouteContext(
type?: string;
};
const base = current.resolved.cdpUrl.replace(/\/$/, "");
const normalizeWsUrl = (raw?: string) => {
if (!raw) return undefined;
try {
return normalizeCdpWsUrl(raw, base);
} catch {
return raw;
}
};
const base = profile.cdpUrl.replace(/\/$/, "");
const endpoint = `${base}/json/new?${encoded}`;
const created = await fetchJson<CdpTarget>(endpoint, 1500, {
method: "PUT",
@@ -181,76 +226,81 @@ export function createBrowserRouteContext(
targetId: created.id,
title: created.title ?? "",
url: created.url ?? url,
wsUrl: normalizeWsUrl(created.webSocketDebuggerUrl),
wsUrl: normalizeWsUrl(created.webSocketDebuggerUrl, base),
type: created.type,
};
};
const isReachable = async (timeoutMs = 300) => {
const current = state();
const wsTimeout = Math.max(200, Math.min(2000, timeoutMs * 2));
return await isChromeCdpReady(
current.resolved.cdpUrl,
timeoutMs,
wsTimeout,
);
return await isChromeCdpReady(profile.cdpUrl, timeoutMs, wsTimeout);
};
const isHttpReachable = async (timeoutMs = 300) => {
const current = state();
return await isChromeReachable(current.resolved.cdpUrl, timeoutMs);
return await isChromeReachable(profile.cdpUrl, timeoutMs);
};
const attachRunning = (running: RunningChrome) => {
opts.setRunning(running);
setProfileRunning(running);
running.proc.on("exit", () => {
const live = opts.getState();
if (live?.running?.pid === running.pid) {
opts.setRunning(null);
// Guard against server teardown (e.g., SIGUSR1 restart)
if (!opts.getState()) return;
const profileState = getProfileState();
if (profileState.running?.pid === running.pid) {
setProfileRunning(null);
}
});
};
const ensureBrowserAvailable = async (): Promise<void> => {
const current = state();
const remoteCdp = !current.resolved.cdpIsLoopback;
const remoteCdp = !profile.cdpIsLoopback;
const profileState = getProfileState();
const httpReachable = await isHttpReachable();
if (!httpReachable) {
if (current.resolved.attachOnly || remoteCdp) {
throw new Error(
remoteCdp
? "Remote CDP is not reachable. Check browser.cdpUrl."
: "Browser attachOnly is enabled and no browser is running.",
? `Remote CDP for profile "${profile.name}" is not reachable at ${profile.cdpUrl}.`
: `Browser attachOnly is enabled and profile "${profile.name}" is not running.`,
);
}
const launched = await launchClawdChrome(current.resolved);
const launched = await launchClawdChrome(current.resolved, profile);
attachRunning(launched);
return;
}
// Port is reachable - check if we own it
if (await isReachable()) return;
// HTTP responds but WebSocket fails - port in use by something else
if (!profileState.running) {
throw new Error(
`Port ${profile.cdpPort} is in use for profile "${profile.name}" but not by clawdis. ` +
`Run action=reset-profile profile=${profile.name} to kill the process.`,
);
}
// We own it but WebSocket failed - restart
if (current.resolved.attachOnly || remoteCdp) {
throw new Error(
remoteCdp
? "Remote CDP websocket is not reachable. Check browser.cdpUrl."
: "Browser attachOnly is enabled and CDP websocket is not reachable.",
? `Remote CDP websocket for profile "${profile.name}" is not reachable.`
: `Browser attachOnly is enabled and CDP websocket for profile "${profile.name}" is not reachable.`,
);
}
if (!current.running) {
throw new Error(
"CDP port responds but websocket handshake failed. Ensure the clawd browser owns the port or stop the conflicting process.",
);
}
await stopClawdChrome(profileState.running);
setProfileRunning(null);
await stopClawdChrome(current.running);
opts.setRunning(null);
const relaunched = await launchClawdChrome(current.resolved);
const relaunched = await launchClawdChrome(current.resolved, profile);
attachRunning(relaunched);
if (!(await isReachable(600))) {
throw new Error("Chrome CDP websocket is not reachable after restart.");
throw new Error(
`Chrome CDP websocket for profile "${profile.name}" is not reachable after restart.`,
);
}
};
@@ -281,8 +331,7 @@ export function createBrowserRouteContext(
};
const focusTab = async (targetId: string): Promise<void> => {
const current = state();
const base = current.resolved.cdpUrl.replace(/\/$/, "");
const base = profile.cdpUrl.replace(/\/$/, "");
const tabs = await listTabs();
const resolved = resolveTargetIdFromTabs(targetId, tabs);
if (!resolved.ok) {
@@ -295,8 +344,7 @@ export function createBrowserRouteContext(
};
const closeTab = async (targetId: string): Promise<void> => {
const current = state();
const base = current.resolved.cdpUrl.replace(/\/$/, "");
const base = profile.cdpUrl.replace(/\/$/, "");
const tabs = await listTabs();
const resolved = resolveTargetIdFromTabs(targetId, tabs);
if (!resolved.ok) {
@@ -309,28 +357,34 @@ export function createBrowserRouteContext(
};
const stopRunningBrowser = async (): Promise<{ stopped: boolean }> => {
const current = state();
if (!current.running) return { stopped: false };
await stopClawdChrome(current.running);
opts.setRunning(null);
const profileState = getProfileState();
if (!profileState.running) return { stopped: false };
await stopClawdChrome(profileState.running);
setProfileRunning(null);
return { stopped: true };
};
const resetProfile = async () => {
const current = state();
if (!current.resolved.cdpIsLoopback) {
throw new Error("reset-profile is only supported for local browsers.");
}
const userDataDir = resolveClawdUserDataDir();
const httpReachable = await isHttpReachable(300);
if (httpReachable && !current.running) {
if (!profile.cdpIsLoopback) {
throw new Error(
"Browser appears to be running but is not owned by clawd. Stop it before resetting the profile.",
`reset-profile is only supported for local profiles (profile "${profile.name}" is remote).`,
);
}
const userDataDir = resolveClawdUserDataDir(profile.name);
const profileState = getProfileState();
if (current.running) {
const httpReachable = await isHttpReachable(300);
if (httpReachable && !profileState.running) {
// Port in use but not by us - kill it
try {
const mod = await import("./pw-ai.js");
await mod.closePlaywrightBrowserConnection();
} catch {
// ignore
}
}
if (profileState.running) {
await stopRunningBrowser();
}
@@ -349,19 +403,8 @@ export function createBrowserRouteContext(
return { moved: true, from: userDataDir, to: moved };
};
const mapTabError = (err: unknown) => {
const msg = String(err);
if (msg.includes("ambiguous target id prefix")) {
return { status: 409, message: "ambiguous target id prefix" };
}
if (msg.includes("tab not found")) {
return { status: 404, message: "tab not found" };
}
return null;
};
return {
state,
profile,
ensureBrowserAvailable,
ensureTabAvailable,
isHttpReachable,
@@ -372,23 +415,116 @@ export function createBrowserRouteContext(
closeTab,
stopRunningBrowser,
resetProfile,
mapTabError,
};
}
async function movePathToTrash(targetPath: string): Promise<string> {
try {
await runExec("trash", [targetPath], { timeoutMs: 10_000 });
return targetPath;
} catch {
const trashDir = path.join(os.homedir(), ".Trash");
fs.mkdirSync(trashDir, { recursive: true });
const base = path.basename(targetPath);
let dest = path.join(trashDir, `${base}-${Date.now()}`);
if (fs.existsSync(dest)) {
dest = path.join(trashDir, `${base}-${Date.now()}-${Math.random()}`);
export function createBrowserRouteContext(
opts: ContextOptions,
): BrowserRouteContext {
const state = () => {
const current = opts.getState();
if (!current) throw new Error("Browser server not started");
return current;
};
const forProfile = (profileName?: string): ProfileContext => {
const current = state();
const name = profileName ?? current.resolved.defaultProfile;
const profile = resolveProfile(current.resolved, name);
if (!profile) {
const available = Object.keys(current.resolved.profiles).join(", ");
throw new Error(
`Profile "${name}" not found. Available profiles: ${available || "(none)"}`,
);
}
fs.renameSync(targetPath, dest);
return dest;
}
return createProfileContext(opts, profile);
};
const listProfiles = async (): Promise<ProfileStatus[]> => {
const current = state();
const result: ProfileStatus[] = [];
for (const name of Object.keys(current.resolved.profiles)) {
const profileState = current.profiles.get(name);
const profile = resolveProfile(current.resolved, name);
if (!profile) continue;
let tabCount = 0;
let running = false;
if (profileState?.running) {
running = true;
try {
const ctx = createProfileContext(opts, profile);
const tabs = await ctx.listTabs();
tabCount = tabs.filter((t) => t.type === "page").length;
} catch {
// Browser might not be responsive
}
} else {
// Check if something is listening on the port
try {
const reachable = await isChromeReachable(profile.cdpUrl, 200);
if (reachable) {
running = true;
const ctx = createProfileContext(opts, profile);
const tabs = await ctx.listTabs().catch(() => []);
tabCount = tabs.filter((t) => t.type === "page").length;
}
} catch {
// Not reachable
}
}
result.push({
name,
cdpPort: profile.cdpPort,
cdpUrl: profile.cdpUrl,
color: profile.color,
running,
tabCount,
isDefault: name === current.resolved.defaultProfile,
isRemote: !profile.cdpIsLoopback,
});
}
return result;
};
// Create default profile context for backward compatibility
const getDefaultContext = () => forProfile();
const mapTabError = (err: unknown) => {
const msg = String(err);
if (msg.includes("ambiguous target id prefix")) {
return { status: 409, message: "ambiguous target id prefix" };
}
if (msg.includes("tab not found")) {
return { status: 404, message: "tab not found" };
}
if (msg.includes("not found")) {
return { status: 404, message: msg };
}
return null;
};
return {
state,
forProfile,
listProfiles,
// Legacy methods delegate to default profile
ensureBrowserAvailable: () => getDefaultContext().ensureBrowserAvailable(),
ensureTabAvailable: (targetId) =>
getDefaultContext().ensureTabAvailable(targetId),
isHttpReachable: (timeoutMs) =>
getDefaultContext().isHttpReachable(timeoutMs),
isReachable: (timeoutMs) => getDefaultContext().isReachable(timeoutMs),
listTabs: () => getDefaultContext().listTabs(),
openTab: (url) => getDefaultContext().openTab(url),
focusTab: (targetId) => getDefaultContext().focusTab(targetId),
closeTab: (targetId) => getDefaultContext().closeTab(targetId),
stopRunningBrowser: () => getDefaultContext().stopRunningBrowser(),
resetProfile: () => getDefaultContext().resetProfile(),
mapTabError,
};
}
+305 -12
View File
@@ -72,26 +72,33 @@ vi.mock("../config/config.js", () => ({
color: "#FF4500",
attachOnly: cfgAttachOnly,
headless: true,
defaultProfile: "clawd",
profiles: {
clawd: { cdpPort: testPort + 1, color: "#FF4500" },
},
},
}),
writeConfigFile: vi.fn(async () => {}),
}));
const launchCalls = vi.hoisted(() => [] as Array<{ port: number }>);
vi.mock("./chrome.js", () => ({
isChromeCdpReady: vi.fn(async () => reachable),
isChromeReachable: vi.fn(async () => reachable),
launchClawdChrome: vi.fn(async (resolved: { cdpPort: number }) => {
launchCalls.push({ port: resolved.cdpPort });
reachable = true;
return {
pid: 123,
exe: { kind: "chrome", path: "/fake/chrome" },
userDataDir: "/tmp/clawd",
cdpPort: resolved.cdpPort,
startedAt: Date.now(),
proc,
};
}),
launchClawdChrome: vi.fn(
async (_resolved: unknown, profile: { cdpPort: number }) => {
launchCalls.push({ port: profile.cdpPort });
reachable = true;
return {
pid: 123,
exe: { kind: "chrome", path: "/fake/chrome" },
userDataDir: "/tmp/clawd",
cdpPort: profile.cdpPort,
startedAt: Date.now(),
proc,
};
},
),
resolveClawdUserDataDir: vi.fn(() => "/tmp/clawd"),
stopClawdChrome: vi.fn(async () => {
reachable = false;
@@ -746,3 +753,289 @@ describe("browser control server", () => {
expect(snapAmbiguous.status).toBe(409);
});
});
describe("backward compatibility (profile parameter)", () => {
beforeEach(async () => {
reachable = false;
cfgAttachOnly = false;
createTargetId = null;
for (const fn of Object.values(pwMocks)) fn.mockClear();
for (const fn of Object.values(cdpMocks)) fn.mockClear();
testPort = await getFreePort();
cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
const u = String(url);
if (u.includes("/json/list")) {
if (!reachable) return makeResponse([]);
return makeResponse([
{
id: "abcd1234",
title: "Tab",
url: "https://example.com",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/abcd1234",
type: "page",
},
]);
}
if (u.includes("/json/new?")) {
return makeResponse({
id: "newtab1",
title: "",
url: "about:blank",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/newtab1",
type: "page",
});
}
if (u.includes("/json/activate/")) return makeResponse("ok");
if (u.includes("/json/close/")) return makeResponse("ok");
return makeResponse({}, { ok: false, status: 500, text: "unexpected" });
}),
);
});
afterEach(async () => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
const { stopBrowserControlServer } = await import("./server.js");
await stopBrowserControlServer();
});
it("GET / without profile uses default profile", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
const status = (await realFetch(`${base}/`).then((r) => r.json())) as {
running: boolean;
profile?: string;
};
expect(status.running).toBe(false);
// Should use default profile (clawd)
expect(status.profile).toBe("clawd");
});
it("POST /start without profile uses default profile", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
const result = (await realFetch(`${base}/start`, { method: "POST" }).then(
(r) => r.json(),
)) as { ok: boolean; profile?: string };
expect(result.ok).toBe(true);
expect(result.profile).toBe("clawd");
});
it("POST /stop without profile uses default profile", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
await realFetch(`${base}/start`, { method: "POST" });
const result = (await realFetch(`${base}/stop`, { method: "POST" }).then(
(r) => r.json(),
)) as { ok: boolean; profile?: string };
expect(result.ok).toBe(true);
expect(result.profile).toBe("clawd");
});
it("GET /tabs without profile uses default profile", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
await realFetch(`${base}/start`, { method: "POST" });
const result = (await realFetch(`${base}/tabs`).then((r) => r.json())) as {
running: boolean;
tabs: unknown[];
};
expect(result.running).toBe(true);
expect(Array.isArray(result.tabs)).toBe(true);
});
it("POST /tabs/open without profile uses default profile", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
await realFetch(`${base}/start`, { method: "POST" });
const result = (await realFetch(`${base}/tabs/open`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: "https://example.com" }),
}).then((r) => r.json())) as { targetId?: string };
expect(result.targetId).toBe("newtab1");
});
it("GET /profiles returns list of profiles", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
const result = (await realFetch(`${base}/profiles`).then((r) =>
r.json(),
)) as { profiles: Array<{ name: string }> };
expect(Array.isArray(result.profiles)).toBe(true);
// Should at least have the default clawd profile
expect(result.profiles.some((p) => p.name === "clawd")).toBe(true);
});
});
describe("profile CRUD endpoints", () => {
beforeEach(async () => {
reachable = false;
cfgAttachOnly = false;
for (const fn of Object.values(pwMocks)) fn.mockClear();
for (const fn of Object.values(cdpMocks)) fn.mockClear();
testPort = await getFreePort();
cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
const u = String(url);
if (u.includes("/json/list")) return makeResponse([]);
return makeResponse({}, { ok: false, status: 500, text: "unexpected" });
}),
);
});
afterEach(async () => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
const { stopBrowserControlServer } = await import("./server.js");
await stopBrowserControlServer();
});
it("POST /profiles/create returns 400 for missing name", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
const result = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(result.status).toBe(400);
const body = (await result.json()) as { error: string };
expect(body.error).toContain("name is required");
});
it("POST /profiles/create returns 400 for invalid name format", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
const result = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Invalid Name!" }),
});
expect(result.status).toBe(400);
const body = (await result.json()) as { error: string };
expect(body.error).toContain("invalid profile name");
});
it("POST /profiles/create returns 409 for duplicate name", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
// "clawd" already exists as the default profile
const result = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "clawd" }),
});
expect(result.status).toBe(409);
const body = (await result.json()) as { error: string };
expect(body.error).toContain("already exists");
});
it("POST /profiles/create accepts cdpUrl for remote profiles", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
const result = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "remote", cdpUrl: "http://10.0.0.42:9222" }),
});
expect(result.status).toBe(200);
const body = (await result.json()) as {
profile?: string;
cdpUrl?: string;
isRemote?: boolean;
};
expect(body.profile).toBe("remote");
expect(body.cdpUrl).toBe("http://10.0.0.42:9222");
expect(body.isRemote).toBe(true);
});
it("POST /profiles/create returns 400 for invalid cdpUrl", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
const result = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "badremote", cdpUrl: "ws://bad" }),
});
expect(result.status).toBe(400);
const body = (await result.json()) as { error: string };
expect(body.error).toContain("cdpUrl");
});
it("DELETE /profiles/:name returns 404 for non-existent profile", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
const result = await realFetch(`${base}/profiles/nonexistent`, {
method: "DELETE",
});
expect(result.status).toBe(404);
const body = (await result.json()) as { error: string };
expect(body.error).toContain("not found");
});
it("DELETE /profiles/:name returns 400 for default profile deletion", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
// clawd is the default profile
const result = await realFetch(`${base}/profiles/clawd`, {
method: "DELETE",
});
expect(result.status).toBe(400);
const body = (await result.json()) as { error: string };
expect(body.error).toContain("cannot delete the default profile");
});
it("DELETE /profiles/:name returns 400 for invalid name format", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
const result = await realFetch(`${base}/profiles/Invalid-Name!`, {
method: "DELETE",
});
expect(result.status).toBe(400);
const body = (await result.json()) as { error: string };
expect(body.error).toContain("invalid profile name");
});
});
+11 -9
View File
@@ -36,9 +36,6 @@ export async function startBrowserControlServerFromConfig(): Promise<BrowserServ
const ctx = createBrowserRouteContext({
getState: () => state,
setRunning: (running) => {
if (state) state.running = running;
},
});
registerBrowserRoutes(app, ctx);
@@ -58,9 +55,8 @@ export async function startBrowserControlServerFromConfig(): Promise<BrowserServ
state = {
server,
port,
cdpPort: resolved.cdpPort,
running: null,
resolved,
profiles: new Map(),
};
logServer.info(`Browser control listening on http://127.0.0.1:${port}/`);
@@ -73,13 +69,19 @@ export async function stopBrowserControlServer(): Promise<void> {
const ctx = createBrowserRouteContext({
getState: () => state,
setRunning: (running) => {
if (state) state.running = running;
},
});
try {
await ctx.stopRunningBrowser();
const current = state;
if (current) {
for (const name of Object.keys(current.resolved.profiles)) {
try {
await ctx.forProfile(name).stopRunningBrowser();
} catch {
// ignore
}
}
}
} catch (err) {
logServer.warn(`clawd browser stop failed: ${String(err)}`);
}
+22
View File
@@ -0,0 +1,22 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { runExec } from "../process/exec.js";
export async function movePathToTrash(targetPath: string): Promise<string> {
try {
await runExec("trash", [targetPath], { timeoutMs: 10_000 });
return targetPath;
} catch {
const trashDir = path.join(os.homedir(), ".Trash");
fs.mkdirSync(trashDir, { recursive: true });
const base = path.basename(targetPath);
let dest = path.join(trashDir, `${base}-${Date.now()}`);
if (fs.existsSync(dest)) {
dest = path.join(trashDir, `${base}-${Date.now()}-${Math.random()}`);
}
fs.renameSync(targetPath, dest);
return dest;
}
}
+118 -62
View File
@@ -64,10 +64,12 @@ export function registerBrowserActionInputCommands(
.action(async (url: string, opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.profile;
try {
const result = await browserNavigate(baseUrl, {
url,
targetId: opts.targetId?.trim() || undefined,
profile,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
@@ -89,18 +91,23 @@ export function registerBrowserActionInputCommands(
.action(async (width: number, height: number, opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.profile;
if (!Number.isFinite(width) || !Number.isFinite(height)) {
defaultRuntime.error(danger("width and height must be numbers"));
defaultRuntime.exit(1);
return;
}
try {
const result = await browserAct(baseUrl, {
kind: "resize",
width,
height,
targetId: opts.targetId?.trim() || undefined,
});
const result = await browserAct(
baseUrl,
{
kind: "resize",
width,
height,
targetId: opts.targetId?.trim() || undefined,
},
{ profile },
);
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -123,6 +130,7 @@ export function registerBrowserActionInputCommands(
.action(async (ref: string | undefined, opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.profile;
const refValue = typeof ref === "string" ? ref.trim() : "";
if (!refValue) {
defaultRuntime.error(danger("ref is required"));
@@ -136,14 +144,18 @@ export function registerBrowserActionInputCommands(
.filter(Boolean)
: undefined;
try {
const result = await browserAct(baseUrl, {
kind: "click",
ref: refValue,
targetId: opts.targetId?.trim() || undefined,
doubleClick: Boolean(opts.double),
button: opts.button?.trim() || undefined,
modifiers,
});
const result = await browserAct(
baseUrl,
{
kind: "click",
ref: refValue,
targetId: opts.targetId?.trim() || undefined,
doubleClick: Boolean(opts.double),
button: opts.button?.trim() || undefined,
modifiers,
},
{ profile },
);
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -167,6 +179,7 @@ export function registerBrowserActionInputCommands(
.action(async (ref: string | undefined, text: string, opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.profile;
const refValue = typeof ref === "string" ? ref.trim() : "";
if (!refValue) {
defaultRuntime.error(danger("ref is required"));
@@ -174,14 +187,18 @@ export function registerBrowserActionInputCommands(
return;
}
try {
const result = await browserAct(baseUrl, {
kind: "type",
ref: refValue,
text,
submit: Boolean(opts.submit),
slowly: Boolean(opts.slowly),
targetId: opts.targetId?.trim() || undefined,
});
const result = await browserAct(
baseUrl,
{
kind: "type",
ref: refValue,
text,
submit: Boolean(opts.submit),
slowly: Boolean(opts.slowly),
targetId: opts.targetId?.trim() || undefined,
},
{ profile },
);
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -201,12 +218,17 @@ export function registerBrowserActionInputCommands(
.action(async (key: string, opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.profile;
try {
const result = await browserAct(baseUrl, {
kind: "press",
key,
targetId: opts.targetId?.trim() || undefined,
});
const result = await browserAct(
baseUrl,
{
kind: "press",
key,
targetId: opts.targetId?.trim() || undefined,
},
{ profile },
);
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -226,12 +248,17 @@ export function registerBrowserActionInputCommands(
.action(async (ref: string, opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.profile;
try {
const result = await browserAct(baseUrl, {
kind: "hover",
ref,
targetId: opts.targetId?.trim() || undefined,
});
const result = await browserAct(
baseUrl,
{
kind: "hover",
ref,
targetId: opts.targetId?.trim() || undefined,
},
{ profile },
);
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -252,13 +279,18 @@ export function registerBrowserActionInputCommands(
.action(async (startRef: string, endRef: string, opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.profile;
try {
const result = await browserAct(baseUrl, {
kind: "drag",
startRef,
endRef,
targetId: opts.targetId?.trim() || undefined,
});
const result = await browserAct(
baseUrl,
{
kind: "drag",
startRef,
endRef,
targetId: opts.targetId?.trim() || undefined,
},
{ profile },
);
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -279,13 +311,18 @@ export function registerBrowserActionInputCommands(
.action(async (ref: string, values: string[], opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.profile;
try {
const result = await browserAct(baseUrl, {
kind: "select",
ref,
values,
targetId: opts.targetId?.trim() || undefined,
});
const result = await browserAct(
baseUrl,
{
kind: "select",
ref,
values,
targetId: opts.targetId?.trim() || undefined,
},
{ profile },
);
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -313,6 +350,7 @@ export function registerBrowserActionInputCommands(
.action(async (paths: string[], opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.profile;
try {
const result = await browserArmFileChooser(baseUrl, {
paths,
@@ -323,6 +361,7 @@ export function registerBrowserActionInputCommands(
timeoutMs: Number.isFinite(opts.timeoutMs)
? opts.timeoutMs
: undefined,
profile,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
@@ -344,16 +383,21 @@ export function registerBrowserActionInputCommands(
.action(async (opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.profile;
try {
const fields = await readFields({
fields: opts.fields,
fieldsFile: opts.fieldsFile,
});
const result = await browserAct(baseUrl, {
kind: "fill",
fields,
targetId: opts.targetId?.trim() || undefined,
});
const result = await browserAct(
baseUrl,
{
kind: "fill",
fields,
targetId: opts.targetId?.trim() || undefined,
},
{ profile },
);
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -380,6 +424,7 @@ export function registerBrowserActionInputCommands(
.action(async (opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.profile;
const accept = opts.accept ? true : opts.dismiss ? false : undefined;
if (accept === undefined) {
defaultRuntime.error(danger("Specify --accept or --dismiss"));
@@ -394,6 +439,7 @@ export function registerBrowserActionInputCommands(
timeoutMs: Number.isFinite(opts.timeoutMs)
? opts.timeoutMs
: undefined,
profile,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
@@ -416,14 +462,19 @@ export function registerBrowserActionInputCommands(
.action(async (opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.profile;
try {
const result = await browserAct(baseUrl, {
kind: "wait",
timeMs: Number.isFinite(opts.time) ? opts.time : undefined,
text: opts.text?.trim() || undefined,
textGone: opts.textGone?.trim() || undefined,
targetId: opts.targetId?.trim() || undefined,
});
const result = await browserAct(
baseUrl,
{
kind: "wait",
timeMs: Number.isFinite(opts.time) ? opts.time : undefined,
text: opts.text?.trim() || undefined,
textGone: opts.textGone?.trim() || undefined,
targetId: opts.targetId?.trim() || undefined,
},
{ profile },
);
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
@@ -444,18 +495,23 @@ export function registerBrowserActionInputCommands(
.action(async (opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.profile;
if (!opts.fn) {
defaultRuntime.error(danger("Missing --fn"));
defaultRuntime.exit(1);
return;
}
try {
const result = await browserAct(baseUrl, {
kind: "evaluate",
fn: opts.fn,
ref: opts.ref?.trim() || undefined,
targetId: opts.targetId?.trim() || undefined,
});
const result = await browserAct(
baseUrl,
{
kind: "evaluate",
fn: opts.fn,
ref: opts.ref?.trim() || undefined,
targetId: opts.targetId?.trim() || undefined,
},
{ profile },
);
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
+4
View File
@@ -20,10 +20,12 @@ export function registerBrowserActionObserveCommands(
.action(async (opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.profile;
try {
const result = await browserConsoleMessages(baseUrl, {
level: opts.level?.trim() || undefined,
targetId: opts.targetId?.trim() || undefined,
profile,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
@@ -43,9 +45,11 @@ export function registerBrowserActionObserveCommands(
.action(async (opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.profile;
try {
const result = await browserPdfSave(baseUrl, {
targetId: opts.targetId?.trim() || undefined,
profile,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
+4
View File
@@ -24,6 +24,7 @@ export function registerBrowserInspectCommands(
.action(async (targetId: string | undefined, opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.profile;
try {
const result = await browserScreenshotAction(baseUrl, {
targetId: targetId?.trim() || undefined,
@@ -31,6 +32,7 @@ export function registerBrowserInspectCommands(
ref: opts.ref?.trim() || undefined,
element: opts.element?.trim() || undefined,
type: opts.type === "jpeg" ? "jpeg" : "png",
profile,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
@@ -57,12 +59,14 @@ export function registerBrowserInspectCommands(
.action(async (opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.profile;
const format = opts.format === "aria" ? "aria" : "ai";
try {
const result = await browserSnapshot(baseUrl, {
format,
targetId: opts.targetId?.trim() || undefined,
limit: Number.isFinite(opts.limit) ? opts.limit : undefined,
profile,
});
if (opts.out) {
+139 -24
View File
@@ -2,8 +2,11 @@ import type { Command } from "commander";
import {
browserCloseTab,
browserCreateProfile,
browserDeleteProfile,
browserFocusTab,
browserOpenTab,
browserProfiles,
browserResetProfile,
browserStart,
browserStatus,
@@ -11,7 +14,7 @@ import {
browserTabs,
resolveBrowserControlUrl,
} from "../browser/client.js";
import { browserAct } from "../browser/client-actions.js";
import { browserAct } from "../browser/client-actions-core.js";
import { danger, info } from "../globals.js";
import { defaultRuntime } from "../runtime.js";
import type { BrowserParentOpts } from "./browser-cli-shared.js";
@@ -27,13 +30,16 @@ export function registerBrowserManageCommands(
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
try {
const status = await browserStatus(baseUrl);
const status = await browserStatus(baseUrl, {
profile: parent?.profile,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(status, null, 2));
return;
}
defaultRuntime.log(
[
`profile: ${status.profile ?? "clawd"}`,
`enabled: ${status.enabled}`,
`running: ${status.running}`,
`controlUrl: ${status.controlUrl}`,
@@ -51,18 +57,22 @@ export function registerBrowserManageCommands(
browser
.command("start")
.description("Start the clawd browser (no-op if already running)")
.description("Start the browser (no-op if already running)")
.action(async (_opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.profile;
try {
await browserStart(baseUrl);
const status = await browserStatus(baseUrl);
await browserStart(baseUrl, { profile });
const status = await browserStatus(baseUrl, { profile });
if (parent?.json) {
defaultRuntime.log(JSON.stringify(status, null, 2));
return;
}
defaultRuntime.log(info(`🦞 clawd browser running: ${status.running}`));
const name = status.profile ?? "clawd";
defaultRuntime.log(
info(`🦞 browser [${name}] running: ${status.running}`),
);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
@@ -71,18 +81,22 @@ export function registerBrowserManageCommands(
browser
.command("stop")
.description("Stop the clawd browser (best-effort)")
.description("Stop the browser (best-effort)")
.action(async (_opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.profile;
try {
await browserStop(baseUrl);
const status = await browserStatus(baseUrl);
await browserStop(baseUrl, { profile });
const status = await browserStatus(baseUrl, { profile });
if (parent?.json) {
defaultRuntime.log(JSON.stringify(status, null, 2));
return;
}
defaultRuntime.log(info(`🦞 clawd browser running: ${status.running}`));
const name = status.profile ?? "clawd";
defaultRuntime.log(
info(`🦞 browser [${name}] running: ${status.running}`),
);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
@@ -91,24 +105,23 @@ export function registerBrowserManageCommands(
browser
.command("reset-profile")
.description("Reset clawd browser profile (moves it to Trash)")
.description("Reset browser profile (moves it to Trash)")
.action(async (_opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.profile;
try {
const result = await browserResetProfile(baseUrl);
const result = await browserResetProfile(baseUrl, { profile });
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
if (!result.moved) {
defaultRuntime.log(info("🦞 clawd browser profile already missing."));
defaultRuntime.log(info(`🦞 browser profile already missing.`));
return;
}
const dest = result.to ?? result.from;
defaultRuntime.log(
info(`🦞 clawd browser profile moved to Trash (${dest})`),
);
defaultRuntime.log(info(`🦞 browser profile moved to Trash (${dest})`));
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
@@ -121,8 +134,9 @@ export function registerBrowserManageCommands(
.action(async (_opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.profile;
try {
const tabs = await browserTabs(baseUrl);
const tabs = await browserTabs(baseUrl, { profile });
if (parent?.json) {
defaultRuntime.log(JSON.stringify({ tabs }, null, 2));
return;
@@ -149,11 +163,12 @@ export function registerBrowserManageCommands(
.command("open")
.description("Open a URL in a new tab")
.argument("<url>", "URL to open")
.action(async (url: string, cmd) => {
.action(async (url: string, _opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.profile;
try {
const tab = await browserOpenTab(baseUrl, url);
const tab = await browserOpenTab(baseUrl, url, { profile });
if (parent?.json) {
defaultRuntime.log(JSON.stringify(tab, null, 2));
return;
@@ -169,11 +184,12 @@ export function registerBrowserManageCommands(
.command("focus")
.description("Focus a tab by target id (or unique prefix)")
.argument("<targetId>", "Target id or unique prefix")
.action(async (targetId: string, cmd) => {
.action(async (targetId: string, _opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.profile;
try {
await browserFocusTab(baseUrl, targetId);
await browserFocusTab(baseUrl, targetId, { profile });
if (parent?.json) {
defaultRuntime.log(JSON.stringify({ ok: true }, null, 2));
return;
@@ -189,14 +205,15 @@ export function registerBrowserManageCommands(
.command("close")
.description("Close a tab (target id optional)")
.argument("[targetId]", "Target id or unique prefix (optional)")
.action(async (targetId: string | undefined, cmd) => {
.action(async (targetId: string | undefined, _opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.profile;
try {
if (targetId?.trim()) {
await browserCloseTab(baseUrl, targetId.trim());
await browserCloseTab(baseUrl, targetId.trim(), { profile });
} else {
await browserAct(baseUrl, { kind: "close" });
await browserAct(baseUrl, { kind: "close" }, { profile });
}
if (parent?.json) {
defaultRuntime.log(JSON.stringify({ ok: true }, null, 2));
@@ -208,4 +225,102 @@ export function registerBrowserManageCommands(
defaultRuntime.exit(1);
}
});
// Profile management commands
browser
.command("profiles")
.description("List all browser profiles")
.action(async (_opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
try {
const profiles = await browserProfiles(baseUrl);
if (parent?.json) {
defaultRuntime.log(JSON.stringify({ profiles }, null, 2));
return;
}
if (profiles.length === 0) {
defaultRuntime.log("No profiles configured.");
return;
}
defaultRuntime.log(
profiles
.map((p) => {
const status = p.running ? "running" : "stopped";
const tabs = p.running ? ` (${p.tabCount} tabs)` : "";
const def = p.isDefault ? " [default]" : "";
const loc = p.isRemote
? `cdpUrl: ${p.cdpUrl}`
: `port: ${p.cdpPort}`;
const remote = p.isRemote ? " [remote]" : "";
return `${p.name}: ${status}${tabs}${def}${remote}\n ${loc}, color: ${p.color}`;
})
.join("\n"),
);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
browser
.command("create-profile")
.description("Create a new browser profile")
.requiredOption(
"--name <name>",
"Profile name (lowercase, numbers, hyphens)",
)
.option("--color <hex>", "Profile color (hex format, e.g. #0066CC)")
.option("--cdp-url <url>", "CDP URL for remote Chrome (http/https)")
.action(
async (opts: { name: string; color?: string; cdpUrl?: string }, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
try {
const result = await browserCreateProfile(baseUrl, {
name: opts.name,
color: opts.color,
cdpUrl: opts.cdpUrl,
});
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
const loc = result.isRemote
? ` cdpUrl: ${result.cdpUrl}`
: ` port: ${result.cdpPort}`;
defaultRuntime.log(
info(
`🦞 Created profile "${result.profile}"\n${loc}\n color: ${result.color}`,
),
);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
},
);
browser
.command("delete-profile")
.description("Delete a browser profile")
.requiredOption("--name <name>", "Profile name to delete")
.action(async (opts: { name: string }, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
try {
const result = await browserDeleteProfile(baseUrl, opts.name);
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
const msg = result.deleted
? `🦞 Deleted profile "${result.profile}" (user data removed)`
: `🦞 Deleted profile "${result.profile}" (no user data found)`;
defaultRuntime.log(info(msg));
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
}
+5 -1
View File
@@ -1 +1,5 @@
export type BrowserParentOpts = { url?: string; json?: boolean };
export type BrowserParentOpts = {
url?: string;
json?: boolean;
profile?: string;
};
Loaded 100 of 148 files, more files were not shown because too many files have changed in this diff. Show more