Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b3f9a5934 | ||
|
|
0372457ba8 | ||
|
|
f28a03c407 | ||
|
|
f5abc8e9c9 | ||
|
|
4b3ca29404 | ||
|
|
259b14d66a | ||
|
|
c9504a6f20 | ||
|
|
5e36e2c3f3 | ||
|
|
d2da305190 | ||
|
|
be9fa124df | ||
|
|
ff88f3c075 | ||
|
|
1315fc4caf | ||
|
|
a03895dfa9 | ||
|
|
40c3898ca1 | ||
|
|
6ea0eb438c | ||
|
|
04cd1bd11a | ||
|
|
fe0b3500cc | ||
|
|
b978cc4e91 | ||
|
|
72a9e58777 | ||
|
|
fd91da2b7f | ||
|
|
5673f4299a | ||
|
|
770daadaf7 | ||
|
|
13c2f22240 | ||
|
|
f2ce455c8c | ||
|
|
640ec465d7 | ||
|
|
70f79bd926 | ||
|
|
7d95f43a75 | ||
|
|
c2f3b653c2 | ||
|
|
12ba32c724 | ||
|
|
0e75aa2716 | ||
|
|
44990d837f | ||
|
|
3a28e3562c | ||
|
|
24aa3e3311 | ||
|
|
3c4c2aa98c | ||
|
|
3ebee63cb3 | ||
|
|
6d6038b855 |
No files matched your search
+5
-1
@@ -16,17 +16,21 @@
|
||||
- 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.
|
||||
- Android nodes: add `sms.send` with permission-gated capability refresh (#172) — thanks @vsabavat.
|
||||
|
||||
### Fixes
|
||||
- Docs: add remote gateway SSH tunnel setup guide (#171) — thanks @jeffersonwarrior.
|
||||
- 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.
|
||||
|
||||
+1
-1
Submodule Peekaboo updated: 9db365b73c...b69e4e8dc0.
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
+10
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -254,41 +254,41 @@ final class AppState {
|
||||
let configRoot = ClawdisConfigFile.loadDict()
|
||||
let configGateway = configRoot["gateway"] as? [String: Any]
|
||||
let configModeRaw = (configGateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let configMode: ConnectionMode? = switch configModeRaw {
|
||||
case "local":
|
||||
.local
|
||||
case "remote":
|
||||
.remote
|
||||
default:
|
||||
nil
|
||||
}
|
||||
let configMode: ConnectionMode? = {
|
||||
switch configModeRaw {
|
||||
case "local":
|
||||
return .local
|
||||
case "remote":
|
||||
return .remote
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}()
|
||||
let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String
|
||||
let configHasRemoteUrl = !(configRemoteUrl?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.isEmpty ?? true)
|
||||
|
||||
let storedMode = UserDefaults.standard.string(forKey: connectionModeKey)
|
||||
let resolvedConnectionMode: ConnectionMode = if let configMode {
|
||||
configMode
|
||||
if let configMode {
|
||||
self.connectionMode = configMode
|
||||
} else if configHasRemoteUrl {
|
||||
.remote
|
||||
self.connectionMode = .remote
|
||||
} else if let storedMode {
|
||||
ConnectionMode(rawValue: storedMode) ?? .local
|
||||
self.connectionMode = ConnectionMode(rawValue: storedMode) ?? .local
|
||||
} else {
|
||||
onboardingSeen ? .local : .unconfigured
|
||||
self.connectionMode = onboardingSeen ? .local : .unconfigured
|
||||
}
|
||||
self.connectionMode = resolvedConnectionMode
|
||||
|
||||
let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
|
||||
let resolvedRemoteTarget = if resolvedConnectionMode == .remote,
|
||||
storedRemoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||
let host = AppState.remoteHost(from: configRemoteUrl)
|
||||
if self.connectionMode == .remote,
|
||||
storedRemoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||
let host = AppState.remoteHost(from: configRemoteUrl)
|
||||
{
|
||||
"\(NSUserName())@\(host)"
|
||||
self.remoteTarget = "\(NSUserName())@\(host)"
|
||||
} else {
|
||||
storedRemoteTarget
|
||||
self.remoteTarget = storedRemoteTarget
|
||||
}
|
||||
self.remoteTarget = resolvedRemoteTarget
|
||||
self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? ""
|
||||
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
|
||||
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
|
||||
@@ -322,6 +322,10 @@ final class AppState {
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
self.configWatcher?.stop()
|
||||
}
|
||||
|
||||
private static func remoteHost(from urlString: String?) -> String? {
|
||||
guard let raw = urlString?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!raw.isEmpty,
|
||||
@@ -357,16 +361,18 @@ final class AppState {
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.isEmpty ?? true)
|
||||
|
||||
let desiredMode: ConnectionMode? = switch modeRaw {
|
||||
case "local":
|
||||
.local
|
||||
case "remote":
|
||||
.remote
|
||||
case "unconfigured":
|
||||
.unconfigured
|
||||
default:
|
||||
nil
|
||||
}
|
||||
let desiredMode: ConnectionMode? = {
|
||||
switch modeRaw {
|
||||
case "local":
|
||||
return .local
|
||||
case "remote":
|
||||
return .remote
|
||||
case "unconfigured":
|
||||
return .unconfigured
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}()
|
||||
|
||||
if let desiredMode {
|
||||
if desiredMode != self.connectionMode {
|
||||
@@ -401,13 +407,14 @@ final class AppState {
|
||||
var gateway = root["gateway"] as? [String: Any] ?? [:]
|
||||
var changed = false
|
||||
|
||||
let desiredMode: String? = switch self.connectionMode {
|
||||
let desiredMode: String?
|
||||
switch self.connectionMode {
|
||||
case .local:
|
||||
"local"
|
||||
desiredMode = "local"
|
||||
case .remote:
|
||||
"remote"
|
||||
desiredMode = "remote"
|
||||
case .unconfigured:
|
||||
nil
|
||||
desiredMode = nil
|
||||
}
|
||||
|
||||
let currentMode = (gateway["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
@@ -244,7 +244,7 @@ actor CameraCaptureService {
|
||||
deviceId: String?) -> AVCaptureDevice?
|
||||
{
|
||||
if let deviceId, !deviceId.isEmpty {
|
||||
if let match = availableCameras().first(where: { $0.uniqueID == deviceId }) {
|
||||
if let match = Self.availableCameras().first(where: { $0.uniqueID == deviceId }) {
|
||||
return match
|
||||
}
|
||||
}
|
||||
@@ -331,7 +331,7 @@ actor CameraCaptureService {
|
||||
|
||||
private func sleepDelayMs(_ delayMs: Int) async {
|
||||
guard delayMs > 0 else { return }
|
||||
let ns = UInt64(min(delayMs, 10000)) * 1_000_000
|
||||
let ns = UInt64(min(delayMs, 10_000)) * 1_000_000
|
||||
try? await Task.sleep(nanoseconds: ns)
|
||||
}
|
||||
|
||||
|
||||
@@ -100,8 +100,7 @@ enum ClawdisConfigFile {
|
||||
static func gatewayPassword() -> String? {
|
||||
let root = self.loadDict()
|
||||
guard let gateway = root["gateway"] as? [String: Any],
|
||||
let remote = gateway["remote"] as? [String: Any]
|
||||
else {
|
||||
let remote = gateway["remote"] as? [String: Any] else {
|
||||
return nil
|
||||
}
|
||||
return remote["password"] as? String
|
||||
@@ -122,4 +121,5 @@ enum ClawdisConfigFile {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
}
|
||||
@@ -157,7 +157,7 @@ enum CommandResolver {
|
||||
}
|
||||
|
||||
static func findExecutable(named name: String, searchPaths: [String]? = nil) -> String? {
|
||||
for dir in searchPaths ?? self.preferredPaths() {
|
||||
for dir in (searchPaths ?? self.preferredPaths()) {
|
||||
let candidate = (dir as NSString).appendingPathComponent(name)
|
||||
if FileManager.default.isExecutableFile(atPath: candidate) {
|
||||
return candidate
|
||||
|
||||
@@ -90,8 +90,8 @@ extension ConfigFileWatcher {
|
||||
private func handleEvents(
|
||||
numEvents: Int,
|
||||
eventPaths: UnsafeMutableRawPointer?,
|
||||
eventFlags: UnsafePointer<FSEventStreamEventFlags>?)
|
||||
{
|
||||
eventFlags: UnsafePointer<FSEventStreamEventFlags>?
|
||||
) {
|
||||
guard numEvents > 0 else { return }
|
||||
guard eventFlags != nil else { return }
|
||||
guard self.matchesTarget(eventPaths: eventPaths) else { return }
|
||||
|
||||
@@ -78,7 +78,7 @@ enum ConfigStore {
|
||||
let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys])
|
||||
guard let raw = String(data: data, encoding: .utf8) else {
|
||||
throw NSError(domain: "ConfigStore", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Failed to encode config.",
|
||||
NSLocalizedDescriptionKey: "Failed to encode config."
|
||||
])
|
||||
}
|
||||
let params: [String: AnyCodable] = ["raw": AnyCodable(raw)]
|
||||
@@ -88,7 +88,7 @@ enum ConfigStore {
|
||||
timeoutMs: 10000)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
#if DEBUG
|
||||
static func _testSetOverrides(_ overrides: Overrides) async {
|
||||
await self.overrideStore.setOverride(overrides)
|
||||
}
|
||||
@@ -96,5 +96,5 @@ enum ConfigStore {
|
||||
static func _testClearOverrides() async {
|
||||
await self.overrideStore.setOverride(.init())
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
@@ -492,7 +492,7 @@ struct ConnectionsSettings: View {
|
||||
|
||||
GroupBox("Guilds") {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
ForEach(self.$store.discordGuilds) { $guild in
|
||||
ForEach($store.discordGuilds) { $guild in
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
TextField("guild id or slug", text: $guild.key)
|
||||
|
||||
@@ -172,8 +172,8 @@ struct DiscordGuildForm: Identifiable {
|
||||
requireMention: Bool = false,
|
||||
reactionNotifications: String = "own",
|
||||
users: String = "",
|
||||
channels: [DiscordGuildChannelForm] = [])
|
||||
{
|
||||
channels: [DiscordGuildChannelForm] = []
|
||||
) {
|
||||
self.key = key
|
||||
self.slug = slug
|
||||
self.requireMention = requireMention
|
||||
@@ -473,16 +473,12 @@ final class ConnectionsStore {
|
||||
} else {
|
||||
self.discordMediaMaxMb = ""
|
||||
}
|
||||
if let history = discord?["historyLimit"]?.doubleValue ?? discord?["historyLimit"]?.intValue
|
||||
.map(Double.init)
|
||||
{
|
||||
if let history = discord?["historyLimit"]?.doubleValue ?? discord?["historyLimit"]?.intValue.map(Double.init) {
|
||||
self.discordHistoryLimit = String(Int(history))
|
||||
} else {
|
||||
self.discordHistoryLimit = ""
|
||||
}
|
||||
if let limit = discord?["textChunkLimit"]?.doubleValue ?? discord?["textChunkLimit"]?.intValue
|
||||
.map(Double.init)
|
||||
{
|
||||
if let limit = discord?["textChunkLimit"]?.doubleValue ?? discord?["textChunkLimit"]?.intValue.map(Double.init) {
|
||||
self.discordTextChunkLimit = String(Int(limit))
|
||||
} else {
|
||||
self.discordTextChunkLimit = ""
|
||||
@@ -510,10 +506,9 @@ final class ConnectionsStore {
|
||||
return nil
|
||||
}
|
||||
.joined(separator: ", ") ?? ""
|
||||
let channels: [DiscordGuildChannelForm] = if let channelMap = entry["channels"]?
|
||||
.dictionaryValue
|
||||
{
|
||||
channelMap.map { channelKey, channelValue in
|
||||
let channels: [DiscordGuildChannelForm]
|
||||
if let channelMap = entry["channels"]?.dictionaryValue {
|
||||
channels = channelMap.map { channelKey, channelValue in
|
||||
let channelEntry = channelValue.dictionaryValue ?? [:]
|
||||
let allow = channelEntry["allow"]?.boolValue ?? true
|
||||
let channelRequireMention =
|
||||
@@ -524,7 +519,7 @@ final class ConnectionsStore {
|
||||
requireMention: channelRequireMention)
|
||||
}
|
||||
} else {
|
||||
[]
|
||||
channels = []
|
||||
}
|
||||
return DiscordGuildForm(
|
||||
key: key,
|
||||
|
||||
@@ -12,13 +12,11 @@ struct ContextUsageBar: View {
|
||||
if match == .darkAqua { return base }
|
||||
return base.blended(withFraction: 0.24, of: .black) ?? base
|
||||
}
|
||||
|
||||
private static let trackFill: NSColor = .init(name: nil) { appearance in
|
||||
let match = appearance.bestMatch(from: [.aqua, .darkAqua])
|
||||
if match == .darkAqua { return NSColor.white.withAlphaComponent(0.14) }
|
||||
return NSColor.black.withAlphaComponent(0.12)
|
||||
}
|
||||
|
||||
private static let trackStroke: NSColor = .init(name: nil) { appearance in
|
||||
let match = appearance.bestMatch(from: [.aqua, .darkAqua])
|
||||
if match == .darkAqua { return NSColor.white.withAlphaComponent(0.22) }
|
||||
|
||||
@@ -55,8 +55,8 @@ final class ControlChannel {
|
||||
private(set) var state: ConnectionState = .disconnected {
|
||||
didSet {
|
||||
CanvasManager.shared.refreshDebugStatus()
|
||||
guard oldValue != self.state else { return }
|
||||
switch self.state {
|
||||
guard oldValue != state else { return }
|
||||
switch state {
|
||||
case .connected:
|
||||
self.logger.info("control channel state -> connected")
|
||||
case .connecting:
|
||||
@@ -71,7 +71,6 @@ final class ControlChannel {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private(set) var lastPingMs: Double?
|
||||
|
||||
private let logger = Logger(subsystem: "com.clawdis", category: "control")
|
||||
|
||||
@@ -138,20 +138,17 @@ enum DeviceModelCatalog {
|
||||
if bundle.url(
|
||||
forResource: "ios-device-identifiers",
|
||||
withExtension: "json",
|
||||
subdirectory: self.resourceSubdirectory) != nil
|
||||
{
|
||||
subdirectory: self.resourceSubdirectory) != nil {
|
||||
return bundle
|
||||
}
|
||||
if bundle.url(
|
||||
forResource: "mac-device-identifiers",
|
||||
withExtension: "json",
|
||||
subdirectory: self.resourceSubdirectory) != nil
|
||||
{
|
||||
subdirectory: self.resourceSubdirectory) != nil {
|
||||
return bundle
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private enum NameValue: Decodable {
|
||||
case string(String)
|
||||
case stringArray([String])
|
||||
|
||||
@@ -237,9 +237,8 @@ actor GatewayConnection {
|
||||
guard let snapshot = self.lastSnapshot else { return (nil, nil) }
|
||||
let configPath = snapshot.snapshot.configpath?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let stateDir = snapshot.snapshot.statedir?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return (
|
||||
configPath?.isEmpty == false ? configPath : nil,
|
||||
stateDir?.isEmpty == false ? stateDir : nil)
|
||||
return (configPath?.isEmpty == false ? configPath : nil,
|
||||
stateDir?.isEmpty == false ? stateDir : nil)
|
||||
}
|
||||
|
||||
func subscribe(bufferingNewest: Int = 100) -> AsyncStream<GatewayPush> {
|
||||
@@ -271,9 +270,7 @@ actor GatewayConnection {
|
||||
}
|
||||
|
||||
private func configure(url: URL, token: String?, password: String?) async {
|
||||
if self.client != nil, self.configuredURL == url, self.configuredToken == token,
|
||||
self.configuredPassword == password
|
||||
{
|
||||
if self.client != nil, self.configuredURL == url, self.configuredToken == token, self.configuredPassword == password {
|
||||
return
|
||||
}
|
||||
if let client {
|
||||
|
||||
@@ -40,8 +40,8 @@ actor GatewayEndpointStore {
|
||||
private static func resolveGatewayPassword(
|
||||
isRemote: Bool,
|
||||
root: [String: Any],
|
||||
env: [String: String]) -> String?
|
||||
{
|
||||
env: [String: String]
|
||||
) -> String? {
|
||||
let raw = env["CLAWDIS_GATEWAY_PASSWORD"] ?? ""
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty {
|
||||
@@ -93,11 +93,7 @@ actor GatewayEndpointStore {
|
||||
let password = deps.password()
|
||||
switch initialMode {
|
||||
case .local:
|
||||
self.state = .ready(
|
||||
mode: .local,
|
||||
url: URL(string: "ws://127.0.0.1:\(port)")!,
|
||||
token: token,
|
||||
password: password)
|
||||
self.state = .ready(mode: .local, url: URL(string: "ws://127.0.0.1:\(port)")!, token: token, password: password)
|
||||
case .remote:
|
||||
self.state = .unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel")
|
||||
case .unconfigured:
|
||||
@@ -129,22 +125,14 @@ actor GatewayEndpointStore {
|
||||
switch mode {
|
||||
case .local:
|
||||
let port = self.deps.localPort()
|
||||
self.setState(.ready(
|
||||
mode: .local,
|
||||
url: URL(string: "ws://127.0.0.1:\(port)")!,
|
||||
token: token,
|
||||
password: password))
|
||||
self.setState(.ready(mode: .local, url: URL(string: "ws://127.0.0.1:\(port)")!, token: token, password: password))
|
||||
case .remote:
|
||||
let port = await self.deps.remotePortIfRunning()
|
||||
guard let port else {
|
||||
self.setState(.unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel"))
|
||||
return
|
||||
}
|
||||
self.setState(.ready(
|
||||
mode: .remote,
|
||||
url: URL(string: "ws://127.0.0.1:\(Int(port))")!,
|
||||
token: token,
|
||||
password: password))
|
||||
self.setState(.ready(mode: .remote, url: URL(string: "ws://127.0.0.1:\(Int(port))")!, token: token, password: password))
|
||||
case .unconfigured:
|
||||
self.setState(.unavailable(mode: .unconfigured, reason: "Gateway not configured"))
|
||||
}
|
||||
@@ -225,8 +213,8 @@ extension GatewayEndpointStore {
|
||||
static func _testResolveGatewayPassword(
|
||||
isRemote: Bool,
|
||||
root: [String: Any],
|
||||
env: [String: String]) -> String?
|
||||
{
|
||||
env: [String: String]
|
||||
) -> String? {
|
||||
self.resolveGatewayPassword(isRemote: isRemote, root: root, env: env)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ enum GatewayLaunchAgentManager {
|
||||
}
|
||||
|
||||
private static func gatewayProgramArguments(bundlePath: String, port: Int, bind: String) -> [String] {
|
||||
#if DEBUG
|
||||
#if DEBUG
|
||||
let projectRoot = CommandResolver.projectRoot()
|
||||
if let localBin = CommandResolver.projectClawdisExecutable(projectRoot: projectRoot) {
|
||||
return [localBin, "gateway", "--port", "\(port)", "--bind", bind]
|
||||
@@ -38,7 +38,7 @@ enum GatewayLaunchAgentManager {
|
||||
subcommand: "gateway",
|
||||
extraArgs: ["--port", "\(port)", "--bind", bind])
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath)
|
||||
return [gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind]
|
||||
}
|
||||
@@ -51,7 +51,7 @@ enum GatewayLaunchAgentManager {
|
||||
|
||||
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
|
||||
if enabled {
|
||||
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(self.legacyGatewayLaunchdLabel)"])
|
||||
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(legacyGatewayLaunchdLabel)"])
|
||||
try? FileManager.default.removeItem(at: self.legacyPlistURL)
|
||||
let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath)
|
||||
guard FileManager.default.isExecutableFile(atPath: gatewayBin) else {
|
||||
|
||||
@@ -151,16 +151,16 @@ struct GeneralSettings: View {
|
||||
private func requestLocationAuthorization(mode: ClawdisLocationMode) async -> Bool {
|
||||
guard mode != .off else { return true }
|
||||
let status = CLLocationManager.authorizationStatus()
|
||||
if status == .authorizedAlways || status == .authorized {
|
||||
if mode == .always, status != .authorizedAlways {
|
||||
if status == .authorizedAlways || status == .authorizedWhenInUse {
|
||||
if mode == .always && status != .authorizedAlways {
|
||||
let updated = await LocationPermissionRequester.shared.request(always: true)
|
||||
return updated == .authorizedAlways || updated == .authorized
|
||||
return updated == .authorizedAlways || updated == .authorizedWhenInUse
|
||||
}
|
||||
return true
|
||||
}
|
||||
let updated = await LocationPermissionRequester.shared.request(always: mode == .always)
|
||||
switch updated {
|
||||
case .authorizedAlways, .authorized:
|
||||
case .authorizedAlways, .authorizedWhenInUse:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -6,7 +6,6 @@ enum LaunchAgentManager {
|
||||
FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/LaunchAgents/com.clawdis.mac.plist")
|
||||
}
|
||||
|
||||
private static var legacyPlistURL: URL {
|
||||
FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/LaunchAgents/\(legacyLaunchdLabel).plist")
|
||||
@@ -20,7 +19,7 @@ enum LaunchAgentManager {
|
||||
|
||||
static func set(enabled: Bool, bundlePath: String) async {
|
||||
if enabled {
|
||||
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(self.legacyLaunchdLabel)"])
|
||||
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(legacyLaunchdLabel)"])
|
||||
try? FileManager.default.removeItem(at: self.legacyPlistURL)
|
||||
self.writePlist(bundlePath: bundlePath)
|
||||
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(launchdLabel)"])
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Foundation
|
||||
@_exported import Logging
|
||||
import os
|
||||
import Foundation
|
||||
import OSLog
|
||||
import os
|
||||
|
||||
typealias Logger = Logging.Logger
|
||||
|
||||
@@ -65,15 +65,15 @@ enum ClawdisLogging {
|
||||
}()
|
||||
|
||||
static func bootstrapIfNeeded() {
|
||||
_ = self.didBootstrap
|
||||
_ = Self.didBootstrap
|
||||
}
|
||||
|
||||
static func makeLabel(subsystem: String, category: String) -> String {
|
||||
"\(subsystem)\(self.labelSeparator)\(category)"
|
||||
"\(subsystem)\(Self.labelSeparator)\(category)"
|
||||
}
|
||||
|
||||
static func parseLabel(_ label: String) -> (String, String) {
|
||||
guard let range = label.range(of: labelSeparator) else {
|
||||
guard let range = label.range(of: Self.labelSeparator) else {
|
||||
return ("com.clawdis", label)
|
||||
}
|
||||
let subsystem = String(label[..<range.lowerBound])
|
||||
@@ -91,7 +91,7 @@ extension Logging.Logger {
|
||||
}
|
||||
|
||||
extension Logger.Message.StringInterpolation {
|
||||
mutating func appendInterpolation(_ value: some Any, privacy: OSLogPrivacy) {
|
||||
mutating func appendInterpolation<T>(_ value: T, privacy: OSLogPrivacy) {
|
||||
self.appendInterpolation(String(describing: value))
|
||||
}
|
||||
}
|
||||
@@ -132,15 +132,15 @@ struct ClawdisOSLogHandler: LogHandler {
|
||||
private static func osLogType(for level: Logger.Level) -> OSLogType {
|
||||
switch level {
|
||||
case .trace, .debug:
|
||||
.debug
|
||||
return .debug
|
||||
case .info, .notice:
|
||||
.info
|
||||
return .info
|
||||
case .warning:
|
||||
.default
|
||||
return .default
|
||||
case .error:
|
||||
.error
|
||||
return .error
|
||||
case .critical:
|
||||
.fault
|
||||
return .fault
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ struct ClawdisOSLogHandler: LogHandler {
|
||||
guard !metadata.isEmpty else { return message.description }
|
||||
let meta = metadata
|
||||
.sorted(by: { $0.key < $1.key })
|
||||
.map { "\($0.key)=\(self.stringify($0.value))" }
|
||||
.map { "\($0.key)=\(stringify($0.value))" }
|
||||
.joined(separator: " ")
|
||||
return "\(message.description) [\(meta)]"
|
||||
}
|
||||
@@ -168,9 +168,9 @@ struct ClawdisOSLogHandler: LogHandler {
|
||||
case let .stringConvertible(value):
|
||||
String(describing: value)
|
||||
case let .array(values):
|
||||
"[" + values.map { self.stringify($0) }.joined(separator: ",") + "]"
|
||||
"[" + values.map { stringify($0) }.joined(separator: ",") + "]"
|
||||
case let .dictionary(entries):
|
||||
"{" + entries.map { "\($0.key)=\(self.stringify($0.value))" }.joined(separator: ",") + "}"
|
||||
"{" + entries.map { "\($0.key)=\(stringify($0.value))" }.joined(separator: ",") + "}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -224,9 +224,9 @@ struct ClawdisFileLogHandler: LogHandler {
|
||||
case let .stringConvertible(value):
|
||||
String(describing: value)
|
||||
case let .array(values):
|
||||
"[" + values.map { self.stringify($0) }.joined(separator: ",") + "]"
|
||||
"[" + values.map { stringify($0) }.joined(separator: ",") + "]"
|
||||
case let .dictionary(entries):
|
||||
"{" + entries.map { "\($0.key)=\(self.stringify($0.value))" }.joined(separator: ",") + "}"
|
||||
"{" + entries.map { "\($0.key)=\(stringify($0.value))" }.joined(separator: ",") + "}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,7 +172,7 @@ struct MenuContent: View {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func buildAndSaveBrowserEnabled(_ enabled: Bool) async -> (Bool, ()) {
|
||||
private static func buildAndSaveBrowserEnabled(_ enabled: Bool) async -> (Bool,()) {
|
||||
var root = await ConfigStore.load()
|
||||
var browser = root["browser"] as? [String: Any] ?? [:]
|
||||
browser["enabled"] = enabled
|
||||
|
||||
@@ -86,6 +86,7 @@ final class HighlightedMenuItemHostView: NSView {
|
||||
self.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height))
|
||||
self.invalidateIntrinsicContentSize()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct MenuHostedHighlightedItem: NSViewRepresentable {
|
||||
|
||||
@@ -435,7 +435,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
compact.representedObject = row.key
|
||||
menu.addItem(compact)
|
||||
|
||||
if row.key != "main", row.key != "global" {
|
||||
if row.key != "main" && row.key != "global" {
|
||||
let del = NSMenuItem(title: "Delete Session", action: #selector(self.deleteSession(_:)), keyEquivalent: "")
|
||||
del.target = self
|
||||
del.representedObject = row.key
|
||||
@@ -541,14 +541,12 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
menu.addItem(self.makeNodeDetailItem(label: "Paired", value: entry.isPaired ? "Yes" : "No"))
|
||||
|
||||
if let caps = entry.caps?.filter({ !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }),
|
||||
!caps.isEmpty
|
||||
{
|
||||
!caps.isEmpty {
|
||||
menu.addItem(self.makeNodeCopyItem(label: "Caps", value: caps.joined(separator: ", ")))
|
||||
}
|
||||
|
||||
if let commands = entry.commands?.filter({ !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }),
|
||||
!commands.isEmpty
|
||||
{
|
||||
!commands.isEmpty {
|
||||
menu.addItem(self.makeNodeMultilineItem(
|
||||
label: "Commands",
|
||||
value: commands.joined(separator: ", "),
|
||||
@@ -591,7 +589,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
@objc
|
||||
private func patchThinking(_ sender: NSMenuItem) {
|
||||
guard let dict = sender.representedObject as? [String: Any],
|
||||
@@ -773,7 +770,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
}
|
||||
|
||||
private func sortedNodeEntries() -> [NodeInfo] {
|
||||
let entries = self.nodesStore.nodes.filter(\.isConnected)
|
||||
let entries = self.nodesStore.nodes.filter { $0.isConnected }
|
||||
return entries.sorted { lhs, rhs in
|
||||
if lhs.isConnected != rhs.isConnected { return lhs.isConnected }
|
||||
if lhs.isPaired != rhs.isPaired { return lhs.isPaired }
|
||||
@@ -784,6 +781,8 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// MARK: - Views
|
||||
|
||||
private func makeHostedView(rootView: AnyView, width: CGFloat, highlighted: Bool) -> NSView {
|
||||
|
||||
@@ -192,4 +192,6 @@ actor MacNodeBridgePairingClient {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -325,4 +325,6 @@ actor MacNodeBridgeSession {
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import CoreLocation
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class MacNodeLocationService: NSObject {
|
||||
final class MacNodeLocationService: NSObject, CLLocationManagerDelegate {
|
||||
enum Error: Swift.Error {
|
||||
case timeout
|
||||
case unavailable
|
||||
@@ -47,8 +47,10 @@ final class MacNodeLocationService: NSObject {
|
||||
}
|
||||
|
||||
self.manager.desiredAccuracy = Self.accuracyValue(desiredAccuracy)
|
||||
let timeout = max(0, timeoutMs ?? 10000)
|
||||
return try await self.requestLocationWithTimeout(timeoutMs: timeout)
|
||||
let timeout = max(0, timeoutMs ?? 10_000)
|
||||
return try await self.withTimeout(timeoutMs: timeout) {
|
||||
try await self.requestLocation()
|
||||
}
|
||||
}
|
||||
|
||||
private func requestLocation() async throws -> CLLocation {
|
||||
@@ -58,69 +60,57 @@ final class MacNodeLocationService: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func requestLocationWithTimeout(timeoutMs: Int) async throws -> CLLocation {
|
||||
private func withTimeout<T>(
|
||||
timeoutMs: Int,
|
||||
operation: @escaping () async throws -> T) async throws -> T
|
||||
{
|
||||
if timeoutMs == 0 {
|
||||
return try await self.requestLocation()
|
||||
return try await operation()
|
||||
}
|
||||
|
||||
let timeoutNs = UInt64(timeoutMs) * 1_000_000
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
let lock = NSLock()
|
||||
var didResume = false
|
||||
|
||||
func resume(_ result: Result<CLLocation, Swift.Error>) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
guard !didResume else { return }
|
||||
didResume = true
|
||||
continuation.resume(with: result)
|
||||
}
|
||||
|
||||
let timeoutTask = Task {
|
||||
try await Task.sleep(nanoseconds: timeoutNs)
|
||||
resume(.failure(Error.timeout))
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
do {
|
||||
let location = try await self.requestLocation()
|
||||
timeoutTask.cancel()
|
||||
resume(.success(location))
|
||||
} catch {
|
||||
timeoutTask.cancel()
|
||||
resume(.failure(error))
|
||||
}
|
||||
return try await withThrowingTaskGroup(of: T.self) { group in
|
||||
group.addTask { try await operation() }
|
||||
group.addTask {
|
||||
try await Task.sleep(nanoseconds: UInt64(timeoutMs) * 1_000_000)
|
||||
throw Error.timeout
|
||||
}
|
||||
let result = try await group.next()!
|
||||
group.cancelAll()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
private static func accuracyValue(_ accuracy: ClawdisLocationAccuracy) -> CLLocationAccuracy {
|
||||
switch accuracy {
|
||||
case .coarse:
|
||||
kCLLocationAccuracyKilometer
|
||||
return kCLLocationAccuracyKilometer
|
||||
case .balanced:
|
||||
kCLLocationAccuracyHundredMeters
|
||||
return kCLLocationAccuracyHundredMeters
|
||||
case .precise:
|
||||
kCLLocationAccuracyBest
|
||||
return kCLLocationAccuracyBest
|
||||
}
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||
guard let cont = self.locationContinuation else { return }
|
||||
self.locationContinuation = nil
|
||||
if let latest = locations.last {
|
||||
cont.resume(returning: latest)
|
||||
} else {
|
||||
cont.resume(throwing: Error.unavailable)
|
||||
// MARK: - CLLocationManagerDelegate (nonisolated for Swift 6 compatibility)
|
||||
|
||||
nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||
Task { @MainActor in
|
||||
guard let cont = self.locationContinuation else { return }
|
||||
self.locationContinuation = nil
|
||||
if let latest = locations.last {
|
||||
cont.resume(returning: latest)
|
||||
} else {
|
||||
cont.resume(throwing: Error.unavailable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didFailWithError error: Swift.Error) {
|
||||
guard let cont = self.locationContinuation else { return }
|
||||
self.locationContinuation = nil
|
||||
cont.resume(throwing: error)
|
||||
nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Swift.Error) {
|
||||
let errorCopy = error // Capture error for Sendable compliance
|
||||
Task { @MainActor in
|
||||
guard let cont = self.locationContinuation else { return }
|
||||
self.locationContinuation = nil
|
||||
cont.resume(throwing: errorCopy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
extension MacNodeLocationService: @preconcurrency CLLocationManagerDelegate {}
|
||||
@@ -104,7 +104,7 @@ actor MacNodeRuntime {
|
||||
}
|
||||
let params = (try? Self.decodeParams(ClawdisCameraSnapParams.self, from: req.paramsJSON)) ??
|
||||
ClawdisCameraSnapParams()
|
||||
let delayMs = min(10000, max(0, params.delayMs ?? 2000))
|
||||
let delayMs = min(10_000, max(0, params.delayMs ?? 2000))
|
||||
let res = try await self.cameraCapture.snap(
|
||||
facing: CameraFacing(rawValue: params.facing?.rawValue ?? "") ?? .front,
|
||||
maxWidth: params.maxWidth,
|
||||
@@ -184,7 +184,7 @@ actor MacNodeRuntime {
|
||||
let desired = params.desiredAccuracy ??
|
||||
(Self.locationPreciseEnabled() ? .precise : .balanced)
|
||||
let status = await self.locationService.authorizationStatus()
|
||||
if status != .authorizedAlways, status != .authorized {
|
||||
if status != .authorizedAlways && status != .authorizedWhenInUse {
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
|
||||
@@ -611,7 +611,7 @@ final class NodePairingApprovalPrompter {
|
||||
private func updatePendingCounts() {
|
||||
// Keep a cheap observable summary for the menu bar status line.
|
||||
self.pendingCount = self.queue.count
|
||||
self.pendingRepairCount = self.queue.count(where: { $0.isRepair == true })
|
||||
self.pendingRepairCount = self.queue.filter { $0.isRepair == true }.count
|
||||
}
|
||||
|
||||
private func reconcileOnce(timeoutMs: Double) async {
|
||||
|
||||
@@ -110,8 +110,8 @@ struct NodeMenuEntryFormatter {
|
||||
guard !trimmed.isEmpty else { return trimmed }
|
||||
if let range = trimmed.range(
|
||||
of: #"\s*\([^)]*\d[^)]*\)$"#,
|
||||
options: .regularExpression)
|
||||
{
|
||||
options: .regularExpression
|
||||
) {
|
||||
return String(trimmed[..<range.lowerBound])
|
||||
}
|
||||
return trimmed
|
||||
@@ -227,6 +227,7 @@ struct NodeMenuRowView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.leading, 18)
|
||||
|
||||
@@ -139,7 +139,6 @@ struct OnboardingView: View {
|
||||
var isWizardBlocking: Bool {
|
||||
self.activePageIndex == self.wizardPageIndex && !self.onboardingWizard.isComplete
|
||||
}
|
||||
|
||||
var canAdvance: Bool { !self.isWizardBlocking }
|
||||
var devLinkCommand: String {
|
||||
let bundlePath = Bundle.main.bundlePath
|
||||
|
||||
@@ -86,7 +86,7 @@ extension OnboardingView {
|
||||
|
||||
var navigationBar: some View {
|
||||
let wizardLockIndex = self.wizardPageOrderIndex
|
||||
return HStack(spacing: 20) {
|
||||
HStack(spacing: 20) {
|
||||
ZStack(alignment: .leading) {
|
||||
Button(action: {}, label: {
|
||||
Label("Back", systemImage: "chevron.left").labelStyle(.iconOnly)
|
||||
@@ -112,8 +112,7 @@ extension OnboardingView {
|
||||
|
||||
HStack(spacing: 8) {
|
||||
ForEach(0..<self.pageCount, id: \.self) { index in
|
||||
let isLocked = wizardLockIndex != nil && !self.onboardingWizard
|
||||
.isComplete && index > (wizardLockIndex ?? 0)
|
||||
let isLocked = wizardLockIndex != nil && !self.onboardingWizard.isComplete && index > (wizardLockIndex ?? 0)
|
||||
Button {
|
||||
withAnimation { self.currentPage = index }
|
||||
} label: {
|
||||
|
||||
@@ -444,7 +444,7 @@ extension OnboardingView {
|
||||
}
|
||||
|
||||
func permissionsPage() -> some View {
|
||||
self.onboardingPage {
|
||||
return self.onboardingPage {
|
||||
Text("Grant permissions")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
Text("These macOS permissions let Clawdis automate apps and capture context on this Mac.")
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import ClawdisProtocol
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
@@ -45,15 +44,15 @@ private struct OnboardingWizardCardContent: View {
|
||||
|
||||
private var state: CardState {
|
||||
if let error = wizard.errorMessage { return .error(error) }
|
||||
if self.wizard.isStarting { return .starting }
|
||||
if wizard.isStarting { return .starting }
|
||||
if let step = wizard.currentStep { return .step(step) }
|
||||
if self.wizard.isComplete { return .complete }
|
||||
if wizard.isComplete { return .complete }
|
||||
return .waiting
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
switch self.state {
|
||||
case let .error(error):
|
||||
switch state {
|
||||
case .error(let error):
|
||||
Text("Wizard error")
|
||||
.font(.headline)
|
||||
Text(error)
|
||||
@@ -61,11 +60,11 @@ private struct OnboardingWizardCardContent: View {
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Button("Retry") {
|
||||
self.wizard.reset()
|
||||
wizard.reset()
|
||||
Task {
|
||||
await self.wizard.startIfNeeded(
|
||||
mode: self.mode,
|
||||
workspace: self.workspacePath.isEmpty ? nil : self.workspacePath)
|
||||
await wizard.startIfNeeded(
|
||||
mode: mode,
|
||||
workspace: workspacePath.isEmpty ? nil : workspacePath)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
@@ -75,12 +74,12 @@ private struct OnboardingWizardCardContent: View {
|
||||
Text("Starting wizard…")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
case let .step(step):
|
||||
case .step(let step):
|
||||
OnboardingWizardStepView(
|
||||
step: step,
|
||||
isSubmitting: self.wizard.isSubmitting)
|
||||
isSubmitting: wizard.isSubmitting)
|
||||
{ value in
|
||||
Task { await self.wizard.submit(step: step, value: value) }
|
||||
Task { await wizard.submit(step: step, value: value) }
|
||||
}
|
||||
.id(step.id)
|
||||
case .complete:
|
||||
|
||||
@@ -101,7 +101,7 @@ extension OnboardingView {
|
||||
do {
|
||||
try await ConfigStore.save(root)
|
||||
return (true, nil)
|
||||
} catch {
|
||||
} catch let error {
|
||||
let errorMessage = "Failed to save config: \(error.localizedDescription)"
|
||||
return (false, errorMessage)
|
||||
}
|
||||
|
||||
@@ -4,9 +4,26 @@ import Observation
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
|
||||
private let onboardingWizardLogger = Logger(subsystem: "com.clawdis", category: "onboarding.wizard")
|
||||
|
||||
// MARK: - Swift 6 AnyCodable Bridging Helpers
|
||||
// Bridge between ClawdisProtocol.AnyCodable and the local module to avoid
|
||||
// Swift 6 strict concurrency type conflicts.
|
||||
|
||||
private typealias ProtocolAnyCodable = ClawdisProtocol.AnyCodable
|
||||
|
||||
private let onboardingWizardLogger = Logger(subsystem: "com.clawdis", category: "onboarding.wizard")
|
||||
private func bridgeToLocal(_ value: ProtocolAnyCodable) -> AnyCodable {
|
||||
if let data = try? JSONEncoder().encode(value),
|
||||
let decoded = try? JSONDecoder().decode(AnyCodable.self, from: data)
|
||||
{
|
||||
return decoded
|
||||
}
|
||||
return AnyCodable(value.value)
|
||||
}
|
||||
|
||||
private func bridgeToLocal(_ value: ProtocolAnyCodable?) -> AnyCodable? {
|
||||
value.map(bridgeToLocal)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
@@ -45,7 +62,7 @@ final class OnboardingWizardModel {
|
||||
let res: WizardStartResult = try await GatewayConnection.shared.requestDecoded(
|
||||
method: .wizardStart,
|
||||
params: params)
|
||||
self.applyStartResult(res)
|
||||
applyStartResult(res)
|
||||
} catch {
|
||||
self.status = "error"
|
||||
self.errorMessage = error.localizedDescription
|
||||
@@ -69,7 +86,7 @@ final class OnboardingWizardModel {
|
||||
let res: WizardNextResult = try await GatewayConnection.shared.requestDecoded(
|
||||
method: .wizardNext,
|
||||
params: params)
|
||||
self.applyNextResult(res)
|
||||
applyNextResult(res)
|
||||
} catch {
|
||||
self.status = "error"
|
||||
self.errorMessage = error.localizedDescription
|
||||
@@ -83,7 +100,7 @@ final class OnboardingWizardModel {
|
||||
let res: WizardStatusResult = try await GatewayConnection.shared.requestDecoded(
|
||||
method: .wizardCancel,
|
||||
params: ["sessionId": AnyCodable(sessionId)])
|
||||
self.applyStatusResult(res)
|
||||
applyStatusResult(res)
|
||||
} catch {
|
||||
self.status = "error"
|
||||
self.errorMessage = error.localizedDescription
|
||||
@@ -105,8 +122,7 @@ final class OnboardingWizardModel {
|
||||
self.currentStep = decodeWizardStep(res.step)
|
||||
if res.done { self.currentStep = nil }
|
||||
if res.done || anyCodableStringValue(res.status) == "done" || anyCodableStringValue(res.status) == "cancelled"
|
||||
|| anyCodableStringValue(res.status) == "error"
|
||||
{
|
||||
|| anyCodableStringValue(res.status) == "error" {
|
||||
self.sessionId = nil
|
||||
}
|
||||
}
|
||||
@@ -145,7 +161,8 @@ struct OnboardingWizardStepView: View {
|
||||
let initialMulti = Set(
|
||||
options.filter { option in
|
||||
anyCodableArray(step.initialvalue).contains { anyCodableEqual($0, option.option.value) }
|
||||
}.map(\.index))
|
||||
}.map { $0.index }
|
||||
)
|
||||
|
||||
_textValue = State(initialValue: initialText)
|
||||
_confirmValue = State(initialValue: initialConfirm)
|
||||
@@ -166,18 +183,18 @@ struct OnboardingWizardStepView: View {
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
switch wizardStepType(self.step) {
|
||||
switch wizardStepType(step) {
|
||||
case "note":
|
||||
EmptyView()
|
||||
case "text":
|
||||
self.textField
|
||||
textField
|
||||
case "confirm":
|
||||
Toggle("", isOn: self.$confirmValue)
|
||||
Toggle("", isOn: $confirmValue)
|
||||
.toggleStyle(.switch)
|
||||
case "select":
|
||||
self.selectOptions
|
||||
selectOptions
|
||||
case "multiselect":
|
||||
self.multiselectOptions
|
||||
multiselectOptions
|
||||
case "progress":
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
@@ -188,25 +205,25 @@ struct OnboardingWizardStepView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Button(action: self.submit) {
|
||||
Text(wizardStepType(self.step) == "action" ? "Run" : "Continue")
|
||||
Button(action: submit) {
|
||||
Text(wizardStepType(step) == "action" ? "Run" : "Continue")
|
||||
.frame(minWidth: 120)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.isSubmitting || self.isBlocked)
|
||||
.disabled(isSubmitting || isBlocked)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var textField: some View {
|
||||
let isSensitive = self.step.sensitive == true
|
||||
let isSensitive = step.sensitive == true
|
||||
if isSensitive {
|
||||
SecureField(self.step.placeholder ?? "", text: self.$textValue)
|
||||
SecureField(step.placeholder ?? "", text: $textValue)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: 360)
|
||||
} else {
|
||||
TextField(self.step.placeholder ?? "", text: self.$textValue)
|
||||
TextField(step.placeholder ?? "", text: $textValue)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: 360)
|
||||
}
|
||||
@@ -214,21 +231,33 @@ struct OnboardingWizardStepView: View {
|
||||
|
||||
private var selectOptions: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(self.optionItems) { item in
|
||||
WizardOptionRow(
|
||||
item: item,
|
||||
isSelected: self.selectedIndex == item.index,
|
||||
onSelect: {
|
||||
self.selectedIndex = item.index
|
||||
})
|
||||
ForEach(optionItems) { item in
|
||||
Button {
|
||||
selectedIndex = item.index
|
||||
} label: {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: selectedIndex == item.index ? "largecircle.fill.circle" : "circle")
|
||||
.foregroundStyle(.accent)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(item.option.label)
|
||||
.foregroundStyle(.primary)
|
||||
if let hint = item.option.hint, !hint.isEmpty {
|
||||
Text(hint)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var multiselectOptions: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(self.optionItems) { item in
|
||||
Toggle(isOn: self.bindingForOption(item)) {
|
||||
ForEach(optionItems) { item in
|
||||
Toggle(isOn: bindingForOption(item)) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(item.option.label)
|
||||
if let hint = item.option.hint, !hint.isEmpty {
|
||||
@@ -244,88 +273,56 @@ struct OnboardingWizardStepView: View {
|
||||
|
||||
private func bindingForOption(_ item: WizardOptionItem) -> Binding<Bool> {
|
||||
Binding(get: {
|
||||
self.selectedIndices.contains(item.index)
|
||||
selectedIndices.contains(item.index)
|
||||
}, set: { newValue in
|
||||
if newValue {
|
||||
self.selectedIndices.insert(item.index)
|
||||
selectedIndices.insert(item.index)
|
||||
} else {
|
||||
self.selectedIndices.remove(item.index)
|
||||
selectedIndices.remove(item.index)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private var isBlocked: Bool {
|
||||
let type = wizardStepType(step)
|
||||
if type == "select" { return self.optionItems.isEmpty }
|
||||
if type == "multiselect" { return self.optionItems.isEmpty }
|
||||
if type == "select" { return optionItems.isEmpty }
|
||||
if type == "multiselect" { return optionItems.isEmpty }
|
||||
return false
|
||||
}
|
||||
|
||||
private func submit() {
|
||||
switch wizardStepType(self.step) {
|
||||
switch wizardStepType(step) {
|
||||
case "note", "progress":
|
||||
self.onSubmit(nil)
|
||||
onSubmit(nil)
|
||||
case "text":
|
||||
self.onSubmit(AnyCodable(self.textValue))
|
||||
onSubmit(AnyCodable(textValue))
|
||||
case "confirm":
|
||||
self.onSubmit(AnyCodable(self.confirmValue))
|
||||
onSubmit(AnyCodable(confirmValue))
|
||||
case "select":
|
||||
guard self.optionItems.indices.contains(self.selectedIndex) else {
|
||||
self.onSubmit(nil)
|
||||
guard optionItems.indices.contains(selectedIndex) else {
|
||||
onSubmit(nil)
|
||||
return
|
||||
}
|
||||
let option = self.optionItems[self.selectedIndex].option
|
||||
self.onSubmit(self.gatewayValue(option.value, fallback: option.label))
|
||||
let option = optionItems[selectedIndex].option
|
||||
onSubmit(bridgeToLocal(option.value) ?? AnyCodable(option.label))
|
||||
case "multiselect":
|
||||
let values = self.optionItems
|
||||
.filter { self.selectedIndices.contains($0.index) }
|
||||
.map { self.gatewayValue($0.option.value, fallback: $0.option.label) }
|
||||
self.onSubmit(AnyCodable(values))
|
||||
let values = optionItems
|
||||
.filter { selectedIndices.contains($0.index) }
|
||||
.map { bridgeToLocal($0.option.value) ?? AnyCodable($0.option.label) }
|
||||
onSubmit(AnyCodable(values))
|
||||
case "action":
|
||||
self.onSubmit(AnyCodable(true))
|
||||
onSubmit(AnyCodable(true))
|
||||
default:
|
||||
self.onSubmit(nil)
|
||||
onSubmit(nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func gatewayValue(_ value: ProtocolAnyCodable?, fallback: String) -> AnyCodable {
|
||||
if let value {
|
||||
return AnyCodable(value.value)
|
||||
}
|
||||
return AnyCodable(fallback)
|
||||
}
|
||||
}
|
||||
|
||||
private struct WizardOptionItem: Identifiable {
|
||||
let index: Int
|
||||
let option: WizardOption
|
||||
|
||||
var id: Int { self.index }
|
||||
}
|
||||
|
||||
private struct WizardOptionRow: View {
|
||||
let item: WizardOptionItem
|
||||
let isSelected: Bool
|
||||
let onSelect: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: self.onSelect) {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: self.isSelected ? "largecircle.fill.circle" : "circle")
|
||||
.foregroundStyle(Color.accentColor)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(self.item.option.label)
|
||||
.foregroundStyle(.primary)
|
||||
if let hint = self.item.option.hint, !hint.isEmpty {
|
||||
Text(hint)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
var id: Int { index }
|
||||
}
|
||||
|
||||
private struct WizardOption {
|
||||
@@ -362,15 +359,15 @@ private func wizardStepType(_ step: WizardStep) -> String {
|
||||
private func anyCodableString(_ value: ProtocolAnyCodable?) -> String {
|
||||
switch value?.value {
|
||||
case let string as String:
|
||||
string
|
||||
return string
|
||||
case let int as Int:
|
||||
String(int)
|
||||
return String(int)
|
||||
case let double as Double:
|
||||
String(double)
|
||||
return String(double)
|
||||
case let bool as Bool:
|
||||
bool ? "true" : "false"
|
||||
return bool ? "true" : "false"
|
||||
default:
|
||||
""
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,44 +378,44 @@ private func anyCodableStringValue(_ value: ProtocolAnyCodable?) -> String? {
|
||||
private func anyCodableBool(_ value: ProtocolAnyCodable?) -> Bool {
|
||||
switch value?.value {
|
||||
case let bool as Bool:
|
||||
bool
|
||||
return bool
|
||||
case let string as String:
|
||||
string.lowercased() == "true"
|
||||
return string.lowercased() == "true"
|
||||
default:
|
||||
false
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func anyCodableArray(_ value: ProtocolAnyCodable?) -> [ProtocolAnyCodable] {
|
||||
switch value?.value {
|
||||
case let arr as [ProtocolAnyCodable]:
|
||||
arr
|
||||
return arr
|
||||
case let arr as [Any]:
|
||||
arr.map { ProtocolAnyCodable($0) }
|
||||
return arr.map { ProtocolAnyCodable($0) }
|
||||
default:
|
||||
[]
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private func anyCodableEqual(_ lhs: ProtocolAnyCodable?, _ rhs: ProtocolAnyCodable?) -> Bool {
|
||||
switch (lhs?.value, rhs?.value) {
|
||||
case let (l as String, r as String):
|
||||
l == r
|
||||
return l == r
|
||||
case let (l as Int, r as Int):
|
||||
l == r
|
||||
return l == r
|
||||
case let (l as Double, r as Double):
|
||||
l == r
|
||||
return l == r
|
||||
case let (l as Bool, r as Bool):
|
||||
l == r
|
||||
return l == r
|
||||
case let (l as String, r as Int):
|
||||
l == String(r)
|
||||
return l == String(r)
|
||||
case let (l as Int, r as String):
|
||||
String(l) == r
|
||||
return String(l) == r
|
||||
case let (l as String, r as Double):
|
||||
l == String(r)
|
||||
return l == String(r)
|
||||
case let (l as Double, r as String):
|
||||
String(l) == r
|
||||
return String(l) == r
|
||||
default:
|
||||
false
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import Foundation
|
||||
import Security
|
||||
import os
|
||||
import PeekabooAutomationKit
|
||||
import PeekabooBridge
|
||||
import PeekabooFoundation
|
||||
import Security
|
||||
|
||||
@MainActor
|
||||
final class PeekabooBridgeHostCoordinator {
|
||||
@@ -80,7 +80,7 @@ final class PeekabooBridgeHostCoordinator {
|
||||
staticCode,
|
||||
SecCSFlags(rawValue: kSecCSSigningInformation),
|
||||
&infoCF) == errSecSuccess,
|
||||
let info = infoCF as? [String: Any]
|
||||
let info = infoCF as? [String: Any]
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -140,12 +140,12 @@ enum PermissionManager {
|
||||
private static func ensureLocation(interactive: Bool) async -> Bool {
|
||||
let status = CLLocationManager.authorizationStatus()
|
||||
switch status {
|
||||
case .authorizedAlways, .authorized:
|
||||
case .authorizedAlways, .authorizedWhenInUse:
|
||||
return true
|
||||
case .notDetermined:
|
||||
guard interactive else { return false }
|
||||
let updated = await LocationPermissionRequester.shared.request(always: false)
|
||||
return updated == .authorizedAlways || updated == .authorized
|
||||
return updated == .authorizedAlways || updated == .authorizedWhenInUse
|
||||
case .denied, .restricted:
|
||||
if interactive {
|
||||
LocationPermissionHelper.openSettings()
|
||||
@@ -198,10 +198,9 @@ enum PermissionManager {
|
||||
|
||||
case .camera:
|
||||
results[cap] = AVCaptureDevice.authorizationStatus(for: .video) == .authorized
|
||||
|
||||
case .location:
|
||||
let status = CLLocationManager.authorizationStatus()
|
||||
results[cap] = status == .authorizedAlways || status == .authorized
|
||||
results[cap] = status == .authorizedAlways || status == .authorizedWhenInUse
|
||||
}
|
||||
}
|
||||
return results
|
||||
@@ -269,7 +268,7 @@ enum LocationPermissionHelper {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class LocationPermissionRequester: NSObject {
|
||||
final class LocationPermissionRequester: NSObject, CLLocationManagerDelegate {
|
||||
static let shared = LocationPermissionRequester()
|
||||
private let manager = CLLocationManager()
|
||||
private var continuation: CheckedContinuation<CLAuthorizationStatus, Never>?
|
||||
@@ -290,16 +289,17 @@ final class LocationPermissionRequester: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||
guard let cont = self.continuation else { return }
|
||||
self.continuation = nil
|
||||
cont.resume(returning: manager.authorizationStatus)
|
||||
// nonisolated for Swift 6 strict concurrency compatibility
|
||||
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||
let status = manager.authorizationStatus
|
||||
Task { @MainActor in
|
||||
guard let cont = self.continuation else { return }
|
||||
self.continuation = nil
|
||||
cont.resume(returning: status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
extension LocationPermissionRequester: @preconcurrency CLLocationManagerDelegate {}
|
||||
|
||||
enum AppleScriptPermission {
|
||||
private static let logger = Logger(subsystem: "com.clawdis", category: "AppleScriptPermission")
|
||||
|
||||
|
||||
@@ -174,13 +174,12 @@ struct SessionMenuPreviewView: View {
|
||||
let timeoutMs = Int(Self.previewTimeoutSeconds * 1000)
|
||||
let payload = try await AsyncTimeout.withTimeout(
|
||||
seconds: Self.previewTimeoutSeconds,
|
||||
onTimeout: { PreviewTimeoutError() })
|
||||
{
|
||||
try await GatewayConnection.shared.chatHistory(
|
||||
sessionKey: self.sessionKey,
|
||||
limit: self.previewLimit,
|
||||
timeoutMs: timeoutMs)
|
||||
}
|
||||
onTimeout: { PreviewTimeoutError() }) {
|
||||
try await GatewayConnection.shared.chatHistory(
|
||||
sessionKey: self.sessionKey,
|
||||
limit: self.previewLimit,
|
||||
timeoutMs: timeoutMs)
|
||||
}
|
||||
let built = Self.previewItems(from: payload, maxItems: self.maxItems)
|
||||
await SessionPreviewCache.shared.store(items: built, for: self.sessionKey)
|
||||
await MainActor.run {
|
||||
@@ -199,9 +198,7 @@ struct SessionMenuPreviewView: View {
|
||||
self.status = .error("Preview unavailable")
|
||||
}
|
||||
}
|
||||
Self.logger
|
||||
.warning(
|
||||
"Session preview failed session=\(self.sessionKey, privacy: .public) error=\(String(describing: error), privacy: .public)")
|
||||
Self.logger.warning("Session preview failed session=\(self.sessionKey, privacy: .public) error=\(String(describing: error), privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -285,7 +285,8 @@ struct TailscaleIntegrationSection: View {
|
||||
requireCredentialsForServe: self.requireCredentialsForServe,
|
||||
password: trimmedPassword,
|
||||
connectionMode: self.connectionMode,
|
||||
isPaused: self.isPaused)
|
||||
isPaused: self.isPaused
|
||||
)
|
||||
|
||||
if !success, let errorMessage {
|
||||
self.statusMessage = errorMessage
|
||||
@@ -306,8 +307,8 @@ struct TailscaleIntegrationSection: View {
|
||||
requireCredentialsForServe: Bool,
|
||||
password: String,
|
||||
connectionMode: AppState.ConnectionMode,
|
||||
isPaused: Bool) async -> (Bool, String?)
|
||||
{
|
||||
isPaused: Bool
|
||||
) async -> (Bool, String?) {
|
||||
var root = await ConfigStore.load()
|
||||
var gateway = root["gateway"] as? [String: Any] ?? [:]
|
||||
var tailscale = gateway["tailscale"] as? [String: Any] ?? [:]
|
||||
@@ -348,7 +349,7 @@ struct TailscaleIntegrationSection: View {
|
||||
do {
|
||||
try await ConfigStore.save(root)
|
||||
return (true, nil)
|
||||
} catch {
|
||||
} catch let error {
|
||||
return (false, error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,14 +35,14 @@ struct TalkOverlayView: View {
|
||||
.frame(width: 18, height: 18)
|
||||
.background(Color.black.opacity(0.4))
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contentShape(Circle())
|
||||
.offset(x: -2, y: -2)
|
||||
.opacity(self.hoveringWindow ? 1 : 0)
|
||||
.animation(.easeOut(duration: 0.12), value: self.hoveringWindow)
|
||||
}
|
||||
.onHover { self.hoveringWindow = $0 }
|
||||
.buttonStyle(.plain)
|
||||
.contentShape(Circle())
|
||||
.offset(x: -2, y: -2)
|
||||
.opacity(self.hoveringWindow ? 1 : 0)
|
||||
.animation(.easeOut(duration: 0.12), value: self.hoveringWindow)
|
||||
}
|
||||
.onHover { self.hoveringWindow = $0 }
|
||||
}
|
||||
.frame(
|
||||
width: TalkOverlayController.overlaySize,
|
||||
@@ -124,7 +124,7 @@ private final class OrbInteractionNSView: NSView {
|
||||
}
|
||||
|
||||
override func mouseUp(with event: NSEvent) {
|
||||
if !self.didDrag, !self.suppressSingleClick {
|
||||
if !self.didDrag && !self.suppressSingleClick {
|
||||
self.onSingleClick?()
|
||||
}
|
||||
self.mouseDownEvent = nil
|
||||
@@ -148,8 +148,8 @@ private struct TalkOrbView: View {
|
||||
} else {
|
||||
TimelineView(.animation) { context in
|
||||
let t = context.date.timeIntervalSinceReferenceDate
|
||||
let listenScale = self.phase == .listening ? (1 + CGFloat(self.level) * 0.12) : 1
|
||||
let pulse = self.phase == .speaking ? (1 + 0.06 * sin(t * 6)) : 1
|
||||
let listenScale = phase == .listening ? (1 + CGFloat(self.level) * 0.12) : 1
|
||||
let pulse = phase == .speaking ? (1 + 0.06 * sin(t * 6)) : 1
|
||||
|
||||
ZStack {
|
||||
Circle()
|
||||
@@ -158,9 +158,9 @@ private struct TalkOrbView: View {
|
||||
.shadow(color: Color.black.opacity(0.22), radius: 10, x: 0, y: 5)
|
||||
.scaleEffect(pulse * listenScale)
|
||||
|
||||
TalkWaveRings(phase: self.phase, level: self.level, time: t, accent: self.accent)
|
||||
TalkWaveRings(phase: phase, level: level, time: t, accent: self.accent)
|
||||
|
||||
if self.phase == .thinking {
|
||||
if phase == .thinking {
|
||||
TalkOrbitArcs(time: t)
|
||||
}
|
||||
}
|
||||
@@ -186,12 +186,11 @@ private struct TalkWaveRings: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
ForEach(0..<3, id: \.self) { idx in
|
||||
let speed = self.phase == .speaking ? 1.4 : self.phase == .listening ? 0.9 : 0.6
|
||||
let speed = phase == .speaking ? 1.4 : phase == .listening ? 0.9 : 0.6
|
||||
let progress = (time * speed + Double(idx) * 0.28).truncatingRemainder(dividingBy: 1)
|
||||
let amplitude = self.phase == .speaking ? 0.95 : self.phase == .listening ? 0.5 + self
|
||||
.level * 0.7 : 0.35
|
||||
let scale = 0.75 + progress * amplitude + (self.phase == .listening ? self.level * 0.15 : 0)
|
||||
let alpha = self.phase == .speaking ? 0.72 : self.phase == .listening ? 0.58 + self.level * 0.28 : 0.4
|
||||
let amplitude = phase == .speaking ? 0.95 : phase == .listening ? 0.5 + level * 0.7 : 0.35
|
||||
let scale = 0.75 + progress * amplitude + (phase == .listening ? level * 0.15 : 0)
|
||||
let alpha = phase == .speaking ? 0.72 : phase == .listening ? 0.58 + level * 0.28 : 0.4
|
||||
Circle()
|
||||
.stroke(self.accent.opacity(alpha - progress * 0.3), lineWidth: 1.6)
|
||||
.scaleEffect(scale)
|
||||
@@ -209,11 +208,11 @@ private struct TalkOrbitArcs: View {
|
||||
Circle()
|
||||
.trim(from: 0.08, to: 0.26)
|
||||
.stroke(Color.white.opacity(0.88), style: StrokeStyle(lineWidth: 1.6, lineCap: .round))
|
||||
.rotationEffect(.degrees(self.time * 42))
|
||||
.rotationEffect(.degrees(time * 42))
|
||||
Circle()
|
||||
.trim(from: 0.62, to: 0.86)
|
||||
.stroke(Color.white.opacity(0.7), style: StrokeStyle(lineWidth: 1.4, lineCap: .round))
|
||||
.rotationEffect(.degrees(-self.time * 35))
|
||||
.rotationEffect(.degrees(-time * 35))
|
||||
}
|
||||
.scaleEffect(1.08)
|
||||
}
|
||||
|
||||
@@ -213,7 +213,7 @@ final class WorkActivityStore {
|
||||
meta: String?,
|
||||
args: [String: AnyCodable]?) -> String
|
||||
{
|
||||
let wrappedArgs = self.wrapToolArgs(args)
|
||||
let wrappedArgs = wrapToolArgs(args)
|
||||
let display = ToolDisplayRegistry.resolve(name: name ?? "tool", args: wrappedArgs, meta: meta)
|
||||
if let detail = display.detailLine, !detail.isEmpty {
|
||||
return "\(display.label): \(detail)"
|
||||
@@ -223,22 +223,22 @@ final class WorkActivityStore {
|
||||
|
||||
private static func wrapToolArgs(_ args: [String: AnyCodable]?) -> ClawdisKit.AnyCodable? {
|
||||
guard let args else { return nil }
|
||||
let converted: [String: Any] = args.mapValues { self.unwrapJSONValue($0.value) }
|
||||
let converted: [String: Any] = args.mapValues { unwrapJSONValue($0.value) }
|
||||
return ClawdisKit.AnyCodable(converted)
|
||||
}
|
||||
|
||||
private static func unwrapJSONValue(_ value: Any) -> Any {
|
||||
if let dict = value as? [String: AnyCodable] {
|
||||
return dict.mapValues { self.unwrapJSONValue($0.value) }
|
||||
return dict.mapValues { unwrapJSONValue($0.value) }
|
||||
}
|
||||
if let array = value as? [AnyCodable] {
|
||||
return array.map { self.unwrapJSONValue($0.value) }
|
||||
return array.map { unwrapJSONValue($0.value) }
|
||||
}
|
||||
if let dict = value as? [String: Any] {
|
||||
return dict.mapValues { self.unwrapJSONValue($0) }
|
||||
return dict.mapValues { unwrapJSONValue($0) }
|
||||
}
|
||||
if let array = value as? [Any] {
|
||||
return array.map { self.unwrapJSONValue($0) }
|
||||
return array.map { unwrapJSONValue($0) }
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ public enum ErrorCode: String, Codable {
|
||||
case unavailable = "UNAVAILABLE"
|
||||
}
|
||||
|
||||
public struct ConnectParams: Codable, Sendable {
|
||||
public struct ConnectParams: Codable {
|
||||
public let minprotocol: Int
|
||||
public let maxprotocol: Int
|
||||
public let client: [String: AnyCodable]
|
||||
@@ -47,7 +47,7 @@ public struct ConnectParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct HelloOk: Codable, Sendable {
|
||||
public struct HelloOk: Codable {
|
||||
public let type: String
|
||||
public let _protocol: Int
|
||||
public let server: [String: AnyCodable]
|
||||
@@ -84,7 +84,7 @@ public struct HelloOk: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct RequestFrame: Codable, Sendable {
|
||||
public struct RequestFrame: Codable {
|
||||
public let type: String
|
||||
public let id: String
|
||||
public let method: String
|
||||
@@ -109,7 +109,7 @@ public struct RequestFrame: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ResponseFrame: Codable, Sendable {
|
||||
public struct ResponseFrame: Codable {
|
||||
public let type: String
|
||||
public let id: String
|
||||
public let ok: Bool
|
||||
@@ -138,7 +138,7 @@ public struct ResponseFrame: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct EventFrame: Codable, Sendable {
|
||||
public struct EventFrame: Codable {
|
||||
public let type: String
|
||||
public let event: String
|
||||
public let payload: AnyCodable?
|
||||
@@ -167,7 +167,7 @@ public struct EventFrame: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct PresenceEntry: Codable, Sendable {
|
||||
public struct PresenceEntry: Codable {
|
||||
public let host: String?
|
||||
public let ip: String?
|
||||
public let version: String?
|
||||
@@ -228,7 +228,7 @@ public struct PresenceEntry: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct StateVersion: Codable, Sendable {
|
||||
public struct StateVersion: Codable {
|
||||
public let presence: Int
|
||||
public let health: Int
|
||||
|
||||
@@ -245,7 +245,7 @@ public struct StateVersion: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct Snapshot: Codable, Sendable {
|
||||
public struct Snapshot: Codable {
|
||||
public let presence: [PresenceEntry]
|
||||
public let health: AnyCodable
|
||||
public let stateversion: StateVersion
|
||||
@@ -278,7 +278,7 @@ public struct Snapshot: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ErrorShape: Codable, Sendable {
|
||||
public struct ErrorShape: Codable {
|
||||
public let code: String
|
||||
public let message: String
|
||||
public let details: AnyCodable?
|
||||
@@ -307,7 +307,7 @@ public struct ErrorShape: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct AgentEvent: Codable, Sendable {
|
||||
public struct AgentEvent: Codable {
|
||||
public let runid: String
|
||||
public let seq: Int
|
||||
public let stream: String
|
||||
@@ -336,7 +336,7 @@ public struct AgentEvent: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SendParams: Codable, Sendable {
|
||||
public struct SendParams: Codable {
|
||||
public let to: String
|
||||
public let message: String
|
||||
public let mediaurl: String?
|
||||
@@ -369,7 +369,7 @@ public struct SendParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct AgentParams: Codable, Sendable {
|
||||
public struct AgentParams: Codable {
|
||||
public let message: String
|
||||
public let to: String?
|
||||
public let sessionid: String?
|
||||
@@ -422,7 +422,7 @@ public struct AgentParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct AgentWaitParams: Codable, Sendable {
|
||||
public struct AgentWaitParams: Codable {
|
||||
public let runid: String
|
||||
public let afterms: Int?
|
||||
public let timeoutms: Int?
|
||||
@@ -443,7 +443,7 @@ public struct AgentWaitParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct WakeParams: Codable, Sendable {
|
||||
public struct WakeParams: Codable {
|
||||
public let mode: AnyCodable
|
||||
public let text: String
|
||||
|
||||
@@ -460,7 +460,7 @@ public struct WakeParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodePairRequestParams: Codable, Sendable {
|
||||
public struct NodePairRequestParams: Codable {
|
||||
public let nodeid: String
|
||||
public let displayname: String?
|
||||
public let platform: String?
|
||||
@@ -509,10 +509,10 @@ public struct NodePairRequestParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodePairListParams: Codable, Sendable {
|
||||
public struct NodePairListParams: Codable {
|
||||
}
|
||||
|
||||
public struct NodePairApproveParams: Codable, Sendable {
|
||||
public struct NodePairApproveParams: Codable {
|
||||
public let requestid: String
|
||||
|
||||
public init(
|
||||
@@ -525,7 +525,7 @@ public struct NodePairApproveParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodePairRejectParams: Codable, Sendable {
|
||||
public struct NodePairRejectParams: Codable {
|
||||
public let requestid: String
|
||||
|
||||
public init(
|
||||
@@ -538,7 +538,7 @@ public struct NodePairRejectParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodePairVerifyParams: Codable, Sendable {
|
||||
public struct NodePairVerifyParams: Codable {
|
||||
public let nodeid: String
|
||||
public let token: String
|
||||
|
||||
@@ -555,7 +555,7 @@ public struct NodePairVerifyParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodeRenameParams: Codable, Sendable {
|
||||
public struct NodeRenameParams: Codable {
|
||||
public let nodeid: String
|
||||
public let displayname: String
|
||||
|
||||
@@ -572,10 +572,10 @@ public struct NodeRenameParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodeListParams: Codable, Sendable {
|
||||
public struct NodeListParams: Codable {
|
||||
}
|
||||
|
||||
public struct NodeDescribeParams: Codable, Sendable {
|
||||
public struct NodeDescribeParams: Codable {
|
||||
public let nodeid: String
|
||||
|
||||
public init(
|
||||
@@ -588,7 +588,7 @@ public struct NodeDescribeParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodeInvokeParams: Codable, Sendable {
|
||||
public struct NodeInvokeParams: Codable {
|
||||
public let nodeid: String
|
||||
public let command: String
|
||||
public let params: AnyCodable?
|
||||
@@ -617,7 +617,7 @@ public struct NodeInvokeParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsListParams: Codable, Sendable {
|
||||
public struct SessionsListParams: Codable {
|
||||
public let limit: Int?
|
||||
public let activeminutes: Int?
|
||||
public let includeglobal: Bool?
|
||||
@@ -642,10 +642,11 @@ public struct SessionsListParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsPatchParams: Codable, Sendable {
|
||||
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, Sendable {
|
||||
key: String,
|
||||
thinkinglevel: AnyCodable?,
|
||||
verboselevel: AnyCodable?,
|
||||
elevatedlevel: AnyCodable?,
|
||||
model: AnyCodable?,
|
||||
sendpolicy: AnyCodable?,
|
||||
groupactivation: AnyCodable?
|
||||
@@ -661,6 +663,7 @@ public struct SessionsPatchParams: Codable, Sendable {
|
||||
self.key = key
|
||||
self.thinkinglevel = thinkinglevel
|
||||
self.verboselevel = verboselevel
|
||||
self.elevatedlevel = elevatedlevel
|
||||
self.model = model
|
||||
self.sendpolicy = sendpolicy
|
||||
self.groupactivation = groupactivation
|
||||
@@ -669,13 +672,14 @@ public struct SessionsPatchParams: Codable, Sendable {
|
||||
case key
|
||||
case thinkinglevel = "thinkingLevel"
|
||||
case verboselevel = "verboseLevel"
|
||||
case elevatedlevel = "elevatedLevel"
|
||||
case model
|
||||
case sendpolicy = "sendPolicy"
|
||||
case groupactivation = "groupActivation"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsResetParams: Codable, Sendable {
|
||||
public struct SessionsResetParams: Codable {
|
||||
public let key: String
|
||||
|
||||
public init(
|
||||
@@ -688,7 +692,7 @@ public struct SessionsResetParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsDeleteParams: Codable, Sendable {
|
||||
public struct SessionsDeleteParams: Codable {
|
||||
public let key: String
|
||||
public let deletetranscript: Bool?
|
||||
|
||||
@@ -705,7 +709,7 @@ public struct SessionsDeleteParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsCompactParams: Codable, Sendable {
|
||||
public struct SessionsCompactParams: Codable {
|
||||
public let key: String
|
||||
public let maxlines: Int?
|
||||
|
||||
@@ -722,10 +726,10 @@ public struct SessionsCompactParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ConfigGetParams: Codable, Sendable {
|
||||
public struct ConfigGetParams: Codable {
|
||||
}
|
||||
|
||||
public struct ConfigSetParams: Codable, Sendable {
|
||||
public struct ConfigSetParams: Codable {
|
||||
public let raw: String
|
||||
|
||||
public init(
|
||||
@@ -738,10 +742,10 @@ public struct ConfigSetParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ConfigSchemaParams: Codable, Sendable {
|
||||
public struct ConfigSchemaParams: Codable {
|
||||
}
|
||||
|
||||
public struct ConfigSchemaResponse: Codable, Sendable {
|
||||
public struct ConfigSchemaResponse: Codable {
|
||||
public let schema: AnyCodable
|
||||
public let uihints: [String: AnyCodable]
|
||||
public let version: String
|
||||
@@ -766,7 +770,7 @@ public struct ConfigSchemaResponse: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct WizardStartParams: Codable, Sendable {
|
||||
public struct WizardStartParams: Codable {
|
||||
public let mode: AnyCodable?
|
||||
public let workspace: String?
|
||||
|
||||
@@ -783,7 +787,7 @@ public struct WizardStartParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct WizardNextParams: Codable, Sendable {
|
||||
public struct WizardNextParams: Codable {
|
||||
public let sessionid: String
|
||||
public let answer: [String: AnyCodable]?
|
||||
|
||||
@@ -800,7 +804,7 @@ public struct WizardNextParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct WizardCancelParams: Codable, Sendable {
|
||||
public struct WizardCancelParams: Codable {
|
||||
public let sessionid: String
|
||||
|
||||
public init(
|
||||
@@ -813,7 +817,7 @@ public struct WizardCancelParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct WizardStatusParams: Codable, Sendable {
|
||||
public struct WizardStatusParams: Codable {
|
||||
public let sessionid: String
|
||||
|
||||
public init(
|
||||
@@ -826,7 +830,7 @@ public struct WizardStatusParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct WizardStep: Codable, Sendable {
|
||||
public struct WizardStep: Codable {
|
||||
public let id: String
|
||||
public let type: AnyCodable
|
||||
public let title: String?
|
||||
@@ -871,7 +875,7 @@ public struct WizardStep: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
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?
|
||||
|
||||
@@ -942,7 +946,7 @@ public struct WizardStatusResult: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct TalkModeParams: Codable, Sendable {
|
||||
public struct TalkModeParams: Codable {
|
||||
public let enabled: Bool
|
||||
public let phase: String?
|
||||
|
||||
@@ -959,7 +963,7 @@ public struct TalkModeParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ProvidersStatusParams: Codable, Sendable {
|
||||
public struct ProvidersStatusParams: Codable {
|
||||
public let probe: Bool?
|
||||
public let timeoutms: Int?
|
||||
|
||||
@@ -976,7 +980,7 @@ public struct ProvidersStatusParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct WebLoginStartParams: Codable, Sendable {
|
||||
public struct WebLoginStartParams: Codable {
|
||||
public let force: Bool?
|
||||
public let timeoutms: Int?
|
||||
public let verbose: Bool?
|
||||
@@ -997,7 +1001,7 @@ public struct WebLoginStartParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct WebLoginWaitParams: Codable, Sendable {
|
||||
public struct WebLoginWaitParams: Codable {
|
||||
public let timeoutms: Int?
|
||||
|
||||
public init(
|
||||
@@ -1010,7 +1014,7 @@ public struct WebLoginWaitParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ModelChoice: Codable, Sendable {
|
||||
public struct ModelChoice: Codable {
|
||||
public let id: String
|
||||
public let name: String
|
||||
public let provider: String
|
||||
@@ -1039,10 +1043,10 @@ public struct ModelChoice: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ModelsListParams: Codable, Sendable {
|
||||
public struct ModelsListParams: Codable {
|
||||
}
|
||||
|
||||
public struct ModelsListResult: Codable, Sendable {
|
||||
public struct ModelsListResult: Codable {
|
||||
public let models: [ModelChoice]
|
||||
|
||||
public init(
|
||||
@@ -1055,10 +1059,10 @@ public struct ModelsListResult: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SkillsStatusParams: Codable, Sendable {
|
||||
public struct SkillsStatusParams: Codable {
|
||||
}
|
||||
|
||||
public struct SkillsInstallParams: Codable, Sendable {
|
||||
public struct SkillsInstallParams: Codable {
|
||||
public let name: String
|
||||
public let installid: String
|
||||
public let timeoutms: Int?
|
||||
@@ -1079,7 +1083,7 @@ public struct SkillsInstallParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SkillsUpdateParams: Codable, Sendable {
|
||||
public struct SkillsUpdateParams: Codable {
|
||||
public let skillkey: String
|
||||
public let enabled: Bool?
|
||||
public let apikey: String?
|
||||
@@ -1104,7 +1108,7 @@ public struct SkillsUpdateParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronJob: Codable, Sendable {
|
||||
public struct CronJob: Codable {
|
||||
public let id: String
|
||||
public let name: String
|
||||
public let description: String?
|
||||
@@ -1161,7 +1165,7 @@ public struct CronJob: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronListParams: Codable, Sendable {
|
||||
public struct CronListParams: Codable {
|
||||
public let includedisabled: Bool?
|
||||
|
||||
public init(
|
||||
@@ -1174,10 +1178,10 @@ public struct CronListParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronStatusParams: Codable, Sendable {
|
||||
public struct CronStatusParams: Codable {
|
||||
}
|
||||
|
||||
public struct CronAddParams: Codable, Sendable {
|
||||
public struct CronAddParams: Codable {
|
||||
public let name: String
|
||||
public let description: String?
|
||||
public let enabled: Bool?
|
||||
@@ -1218,7 +1222,7 @@ public struct CronAddParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronUpdateParams: Codable, Sendable {
|
||||
public struct CronUpdateParams: Codable {
|
||||
public let id: String
|
||||
public let patch: [String: AnyCodable]
|
||||
|
||||
@@ -1235,7 +1239,7 @@ public struct CronUpdateParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronRemoveParams: Codable, Sendable {
|
||||
public struct CronRemoveParams: Codable {
|
||||
public let id: String
|
||||
|
||||
public init(
|
||||
@@ -1248,7 +1252,7 @@ public struct CronRemoveParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronRunParams: Codable, Sendable {
|
||||
public struct CronRunParams: Codable {
|
||||
public let id: String
|
||||
public let mode: AnyCodable?
|
||||
|
||||
@@ -1265,7 +1269,7 @@ public struct CronRunParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronRunsParams: Codable, Sendable {
|
||||
public struct CronRunsParams: Codable {
|
||||
public let id: String
|
||||
public let limit: Int?
|
||||
|
||||
@@ -1282,7 +1286,7 @@ public struct CronRunsParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct CronRunLogEntry: Codable, Sendable {
|
||||
public struct CronRunLogEntry: Codable {
|
||||
public let ts: Int
|
||||
public let jobid: String
|
||||
public let action: String
|
||||
@@ -1327,7 +1331,7 @@ public struct CronRunLogEntry: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChatHistoryParams: Codable, Sendable {
|
||||
public struct ChatHistoryParams: Codable {
|
||||
public let sessionkey: String
|
||||
public let limit: Int?
|
||||
|
||||
@@ -1344,7 +1348,7 @@ public struct ChatHistoryParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChatSendParams: Codable, Sendable {
|
||||
public struct ChatSendParams: Codable {
|
||||
public let sessionkey: String
|
||||
public let message: String
|
||||
public let thinking: String?
|
||||
@@ -1381,7 +1385,7 @@ public struct ChatSendParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChatAbortParams: Codable, Sendable {
|
||||
public struct ChatAbortParams: Codable {
|
||||
public let sessionkey: String
|
||||
public let runid: String
|
||||
|
||||
@@ -1398,7 +1402,7 @@ public struct ChatAbortParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChatEvent: Codable, Sendable {
|
||||
public struct ChatEvent: Codable {
|
||||
public let runid: String
|
||||
public let sessionkey: String
|
||||
public let seq: Int
|
||||
@@ -1439,7 +1443,7 @@ public struct ChatEvent: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct TickEvent: Codable, Sendable {
|
||||
public struct TickEvent: Codable {
|
||||
public let ts: Int
|
||||
|
||||
public init(
|
||||
@@ -1452,7 +1456,7 @@ public struct TickEvent: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ShutdownEvent: Codable, Sendable {
|
||||
public struct ShutdownEvent: Codable {
|
||||
public let reason: String
|
||||
public let restartexpectedms: Int?
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
+39
-2
@@ -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.
|
||||
@@ -692,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`).
|
||||
|
||||
@@ -705,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,
|
||||
|
||||
@@ -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`).
|
||||
@@ -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 |
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
# Running Clawdis.app with a Remote Gateway
|
||||
|
||||
Clawdis.app uses SSH tunneling to connect to a remote gateway. This guide shows you how to set it up.
|
||||
|
||||
## Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ MacBook │
|
||||
│ │
|
||||
│ Clawdis.app ──► ws://127.0.0.1:18789 (local port) │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ SSH Tunnel ────────────────────────────────────────────────│
|
||||
│ │ │
|
||||
└─────────────────────┼──────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Remote Machine │
|
||||
│ │
|
||||
│ Gateway WebSocket ──► ws://127.0.0.1:18789 ──► │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Quick Setup
|
||||
|
||||
### Step 1: Add SSH Config
|
||||
|
||||
Edit `~/.ssh/config` and add:
|
||||
|
||||
```ssh
|
||||
Host remote-gateway
|
||||
HostName <REMOTE_IP> # e.g., 172.27.187.184
|
||||
User <REMOTE_USER> # e.g., jefferson
|
||||
LocalForward 18789 127.0.0.1:18789
|
||||
IdentityFile ~/.ssh/id_rsa
|
||||
```
|
||||
|
||||
Replace `<REMOTE_IP>` and `<REMOTE_USER>` with your values.
|
||||
|
||||
### Step 2: Copy SSH Key
|
||||
|
||||
Copy your public key to the remote machine (enter password once):
|
||||
|
||||
```bash
|
||||
ssh-copy-id -i ~/.ssh/id_rsa <REMOTE_USER>@<REMOTE_IP>
|
||||
```
|
||||
|
||||
### Step 3: Set Gateway Token
|
||||
|
||||
```bash
|
||||
launchctl setenv CLAWDIS_GATEWAY_TOKEN "<your-token>"
|
||||
```
|
||||
|
||||
### Step 4: Start SSH Tunnel
|
||||
|
||||
```bash
|
||||
ssh -N remote-gateway &
|
||||
```
|
||||
|
||||
### Step 5: Restart Clawdis.app
|
||||
|
||||
```bash
|
||||
killall Clawdis
|
||||
open /path/to/Clawdis.app
|
||||
```
|
||||
|
||||
The app will now connect to the remote gateway through the SSH tunnel.
|
||||
|
||||
---
|
||||
|
||||
## Auto-Start Tunnel on Login
|
||||
|
||||
To have the SSH tunnel start automatically when you log in, create a Launch Agent.
|
||||
|
||||
### Create the PLIST file
|
||||
|
||||
Save this as `~/Library/LaunchAgents/com.clawdis.ssh-tunnel.plist`:
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.clawdis.ssh-tunnel</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/usr/bin/ssh</string>
|
||||
<string>-N</string>
|
||||
<string>remote-gateway</string>
|
||||
</array>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
```
|
||||
|
||||
### Load the Launch Agent
|
||||
|
||||
```bash
|
||||
launchctl load ~/Library/LaunchAgents/com.clawdis.ssh-tunnel.plist
|
||||
```
|
||||
|
||||
The tunnel will now:
|
||||
- Start automatically when you log in
|
||||
- Restart if it crashes
|
||||
- Keep running in the background
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Check if tunnel is running:**
|
||||
|
||||
```bash
|
||||
ps aux | grep "ssh -N remote-gateway" | grep -v grep
|
||||
lsof -i :18789
|
||||
```
|
||||
|
||||
**Restart the tunnel:**
|
||||
|
||||
```bash
|
||||
launchctl restart com.clawdis.ssh-tunnel
|
||||
```
|
||||
|
||||
**Stop the tunnel:**
|
||||
|
||||
```bash
|
||||
launchctl unload ~/Library/LaunchAgents/com.clawdis.ssh-tunnel.plist
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
| Component | What It Does |
|
||||
|-----------|--------------|
|
||||
| `LocalForward 18789 127.0.0.1:18789` | Forwards local port 18789 to remote port 18789 |
|
||||
| `ssh -N` | SSH without executing remote commands (just port forwarding) |
|
||||
| `KeepAlive` | Automatically restarts tunnel if it crashes |
|
||||
| `RunAtLoad` | Starts tunnel when the agent loads |
|
||||
|
||||
Clawdis.app connects to `ws://127.0.0.1:18789` on your MacBook. The SSH tunnel forwards that connection to port 18789 on the remote machine where the Gateway is running.
|
||||
+5
-2
@@ -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).
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -106,7 +106,7 @@ function emitStruct(name: string, schema: JsonSchema): string {
|
||||
const props = schema.properties ?? {};
|
||||
const required = new Set(schema.required ?? []);
|
||||
const lines: string[] = [];
|
||||
lines.push(`public struct ${name}: Codable, Sendable {`);
|
||||
lines.push(`public struct ${name}: Codable {`);
|
||||
if (Object.keys(props).length === 0) {
|
||||
lines.push("}\n");
|
||||
return lines.join("\n");
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
Executable
+65
@@ -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
|
||||
@@ -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
@@ -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`)
|
||||
|
||||
@@ -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
@@ -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).
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
+10
-3216
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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,
|
||||
|
||||
@@ -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"] },
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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()),
|
||||
}),
|
||||
]);
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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(", ")})`,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Loaded 100 of 188 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user