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.
|
- 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.
|
- Onboarding: shared wizard engine powering CLI + macOS via gateway wizard RPC.
|
||||||
- Config: expose schema + UI hints for generic config forms (Web UI + future clients).
|
- 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 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.
|
- 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 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.
|
- 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.
|
- 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
|
### 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: fix lint ordering after merge cleanup (#156) — thanks @steipete.
|
||||||
- CI: consolidate checks to avoid redundant installs (#144) — thanks @thewilloftheshadow.
|
- CI: consolidate checks to avoid redundant installs (#144) — thanks @thewilloftheshadow.
|
||||||
- WhatsApp: support `gifPlayback` for MP4 GIF sends via CLI/gateway.
|
- 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: 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.
|
- 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.
|
- 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.ACCESS_BACKGROUND_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.SEND_SMS" />
|
||||||
<uses-feature
|
<uses-feature
|
||||||
android:name="android.hardware.camera"
|
android:name="android.hardware.camera"
|
||||||
android:required="false" />
|
android:required="false" />
|
||||||
|
<uses-feature
|
||||||
|
android:name="android.hardware.telephony"
|
||||||
|
android:required="false" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".NodeApp"
|
android:name=".NodeApp"
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
screenCaptureRequester = ScreenCaptureRequester(this)
|
screenCaptureRequester = ScreenCaptureRequester(this)
|
||||||
viewModel.camera.attachLifecycleOwner(this)
|
viewModel.camera.attachLifecycleOwner(this)
|
||||||
viewModel.camera.attachPermissionRequester(permissionRequester)
|
viewModel.camera.attachPermissionRequester(permissionRequester)
|
||||||
|
viewModel.sms.attachPermissionRequester(permissionRequester)
|
||||||
viewModel.screenRecorder.attachScreenCaptureRequester(screenCaptureRequester)
|
viewModel.screenRecorder.attachScreenCaptureRequester(screenCaptureRequester)
|
||||||
viewModel.screenRecorder.attachPermissionRequester(permissionRequester)
|
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.CameraCaptureManager
|
||||||
import com.clawdis.android.node.CanvasController
|
import com.clawdis.android.node.CanvasController
|
||||||
import com.clawdis.android.node.ScreenRecordManager
|
import com.clawdis.android.node.ScreenRecordManager
|
||||||
|
import com.clawdis.android.node.SmsManager
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
class MainViewModel(app: Application) : AndroidViewModel(app) {
|
class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
@@ -15,6 +16,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
val canvas: CanvasController = runtime.canvas
|
val canvas: CanvasController = runtime.canvas
|
||||||
val camera: CameraCaptureManager = runtime.camera
|
val camera: CameraCaptureManager = runtime.camera
|
||||||
val screenRecorder: ScreenRecordManager = runtime.screenRecorder
|
val screenRecorder: ScreenRecordManager = runtime.screenRecorder
|
||||||
|
val sms: SmsManager = runtime.sms
|
||||||
|
|
||||||
val bridges: StateFlow<List<BridgeEndpoint>> = runtime.bridges
|
val bridges: StateFlow<List<BridgeEndpoint>> = runtime.bridges
|
||||||
val discoveryStatusText: StateFlow<String> = runtime.discoveryStatusText
|
val discoveryStatusText: StateFlow<String> = runtime.discoveryStatusText
|
||||||
@@ -116,6 +118,10 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
runtime.setTalkEnabled(enabled)
|
runtime.setTalkEnabled(enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun refreshBridgeHello() {
|
||||||
|
runtime.refreshBridgeHello()
|
||||||
|
}
|
||||||
|
|
||||||
fun connect(endpoint: BridgeEndpoint) {
|
fun connect(endpoint: BridgeEndpoint) {
|
||||||
runtime.connect(endpoint)
|
runtime.connect(endpoint)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,13 +21,15 @@ import com.clawdis.android.node.LocationCaptureManager
|
|||||||
import com.clawdis.android.BuildConfig
|
import com.clawdis.android.BuildConfig
|
||||||
import com.clawdis.android.node.CanvasController
|
import com.clawdis.android.node.CanvasController
|
||||||
import com.clawdis.android.node.ScreenRecordManager
|
import com.clawdis.android.node.ScreenRecordManager
|
||||||
|
import com.clawdis.android.node.SmsManager
|
||||||
import com.clawdis.android.protocol.ClawdisCapability
|
import com.clawdis.android.protocol.ClawdisCapability
|
||||||
import com.clawdis.android.protocol.ClawdisCameraCommand
|
import com.clawdis.android.protocol.ClawdisCameraCommand
|
||||||
import com.clawdis.android.protocol.ClawdisCanvasA2UIAction
|
import com.clawdis.android.protocol.ClawdisCanvasA2UIAction
|
||||||
import com.clawdis.android.protocol.ClawdisCanvasA2UICommand
|
import com.clawdis.android.protocol.ClawdisCanvasA2UICommand
|
||||||
import com.clawdis.android.protocol.ClawdisCanvasCommand
|
import com.clawdis.android.protocol.ClawdisCanvasCommand
|
||||||
import com.clawdis.android.protocol.ClawdisLocationCommand
|
|
||||||
import com.clawdis.android.protocol.ClawdisScreenCommand
|
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.TalkModeManager
|
||||||
import com.clawdis.android.voice.VoiceWakeManager
|
import com.clawdis.android.voice.VoiceWakeManager
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -61,6 +63,7 @@ class NodeRuntime(context: Context) {
|
|||||||
val camera = CameraCaptureManager(appContext)
|
val camera = CameraCaptureManager(appContext)
|
||||||
val location = LocationCaptureManager(appContext)
|
val location = LocationCaptureManager(appContext)
|
||||||
val screenRecorder = ScreenRecordManager(appContext)
|
val screenRecorder = ScreenRecordManager(appContext)
|
||||||
|
val sms = SmsManager(appContext)
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
private val externalAudioCaptureActive = MutableStateFlow(false)
|
private val externalAudioCaptureActive = MutableStateFlow(false)
|
||||||
@@ -364,69 +367,112 @@ class NodeRuntime(context: Context) {
|
|||||||
prefs.setTalkEnabled(value)
|
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) {
|
fun connect(endpoint: BridgeEndpoint) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
_statusText.value = "Connecting…"
|
_statusText.value = "Connecting…"
|
||||||
val storedToken = prefs.loadBridgeToken()
|
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 =
|
val resolved =
|
||||||
if (storedToken.isNullOrBlank()) {
|
if (storedToken.isNullOrBlank()) {
|
||||||
_statusText.value = "Pairing…"
|
_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(
|
BridgePairingClient().pairAndHello(
|
||||||
endpoint = endpoint,
|
endpoint = endpoint,
|
||||||
hello =
|
hello = buildPairingHello(token = null),
|
||||||
BridgePairingClient.Hello(
|
|
||||||
nodeId = instanceId.value,
|
|
||||||
displayName = displayName.value,
|
|
||||||
token = null,
|
|
||||||
platform = "Android",
|
|
||||||
version = advertisedVersion,
|
|
||||||
deviceFamily = "Android",
|
|
||||||
modelIdentifier = modelIdentifier,
|
|
||||||
caps = caps,
|
|
||||||
commands = invokeCommands,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
BridgePairingClient.PairResult(ok = true, token = storedToken.trim())
|
BridgePairingClient.PairResult(ok = true, token = storedToken.trim())
|
||||||
@@ -440,38 +486,9 @@ class NodeRuntime(context: Context) {
|
|||||||
|
|
||||||
val authToken = requireNotNull(resolved.token).trim()
|
val authToken = requireNotNull(resolved.token).trim()
|
||||||
prefs.saveBridgeToken(authToken)
|
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(
|
session.connect(
|
||||||
endpoint = endpoint,
|
endpoint = endpoint,
|
||||||
hello =
|
hello = buildSessionHello(token = authToken),
|
||||||
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,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -911,6 +928,17 @@ class NodeRuntime(context: Context) {
|
|||||||
_screenRecordActive.value = false
|
_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 ->
|
else ->
|
||||||
BridgeSession.InvokeResult.error(
|
BridgeSession.InvokeResult.error(
|
||||||
code = "INVALID_REQUEST",
|
code = "INVALID_REQUEST",
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ class PermissionRequester(private val activity: ComponentActivity) {
|
|||||||
|
|
||||||
private fun buildRationaleMessage(permissions: List<String>): String {
|
private fun buildRationaleMessage(permissions: List<String>): String {
|
||||||
val labels = permissions.map { permissionLabel(it) }
|
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 {
|
private fun buildSettingsMessage(permissions: List<String>): String {
|
||||||
@@ -127,6 +127,7 @@ class PermissionRequester(private val activity: ComponentActivity) {
|
|||||||
when (permission) {
|
when (permission) {
|
||||||
Manifest.permission.CAMERA -> "Camera"
|
Manifest.permission.CAMERA -> "Camera"
|
||||||
Manifest.permission.RECORD_AUDIO -> "Microphone"
|
Manifest.permission.RECORD_AUDIO -> "Microphone"
|
||||||
|
Manifest.permission.SEND_SMS -> "SMS"
|
||||||
else -> permission
|
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() {
|
fun disconnect() {
|
||||||
desired = null
|
desired = null
|
||||||
// Unblock connectOnce() read loop. Coroutine cancellation alone won't interrupt BufferedReader.readLine().
|
// Unblock connectOnce() read loop. Coroutine cancellation alone won't interrupt BufferedReader.readLine().
|
||||||
@@ -196,20 +203,7 @@ class BridgeSession(
|
|||||||
currentConnection = conn
|
currentConnection = conn
|
||||||
|
|
||||||
try {
|
try {
|
||||||
conn.sendJson(
|
conn.sendJson(buildHelloJson(hello))
|
||||||
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))) }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
val firstLine = reader.readLine() ?: throw IllegalStateException("bridge closed connection")
|
val firstLine = reader.readLine() ?: throw IllegalStateException("bridge closed connection")
|
||||||
val first = json.parseToJsonElement(firstLine).asObjectOrNull()
|
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? {
|
private fun normalizeCanvasHostUrl(raw: String?, endpoint: BridgeEndpoint): String? {
|
||||||
val trimmed = raw?.trim().orEmpty()
|
val trimmed = raw?.trim().orEmpty()
|
||||||
val parsed = trimmed.takeIf { it.isNotBlank() }?.let { runCatching { URI(it) }.getOrNull() }
|
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"),
|
Canvas("canvas"),
|
||||||
Camera("camera"),
|
Camera("camera"),
|
||||||
Screen("screen"),
|
Screen("screen"),
|
||||||
|
Sms("sms"),
|
||||||
VoiceWake("voiceWake"),
|
VoiceWake("voiceWake"),
|
||||||
Location("location"),
|
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) {
|
enum class ClawdisLocationCommand(val rawValue: String) {
|
||||||
Get("location.get"),
|
Get("location.get"),
|
||||||
;
|
;
|
||||||
|
|||||||
@@ -149,6 +149,23 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
// Status text is handled by NodeRuntime.
|
// 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) {
|
fun setCameraEnabledChecked(checked: Boolean) {
|
||||||
if (!checked) {
|
if (!checked) {
|
||||||
viewModel.setCameraEnabled(false)
|
viewModel.setCameraEnabled(false)
|
||||||
@@ -233,7 +250,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(6.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 { Text("Node", style = MaterialTheme.typography.titleSmall) }
|
||||||
item {
|
item {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
@@ -507,6 +524,46 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
|
|
||||||
item { HorizontalDivider() }
|
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
|
// Location
|
||||||
item { Text("Location", style = MaterialTheme.typography.titleSmall) }
|
item { Text("Location", style = MaterialTheme.typography.titleSmall) }
|
||||||
item {
|
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 configRoot = ClawdisConfigFile.loadDict()
|
||||||
let configGateway = configRoot["gateway"] as? [String: Any]
|
let configGateway = configRoot["gateway"] as? [String: Any]
|
||||||
let configModeRaw = (configGateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
let configModeRaw = (configGateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let configMode: ConnectionMode? = switch configModeRaw {
|
let configMode: ConnectionMode? = {
|
||||||
case "local":
|
switch configModeRaw {
|
||||||
.local
|
case "local":
|
||||||
case "remote":
|
return .local
|
||||||
.remote
|
case "remote":
|
||||||
default:
|
return .remote
|
||||||
nil
|
default:
|
||||||
}
|
return nil
|
||||||
|
}
|
||||||
|
}()
|
||||||
let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String
|
let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String
|
||||||
let configHasRemoteUrl = !(configRemoteUrl?
|
let configHasRemoteUrl = !(configRemoteUrl?
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
.isEmpty ?? true)
|
.isEmpty ?? true)
|
||||||
|
|
||||||
let storedMode = UserDefaults.standard.string(forKey: connectionModeKey)
|
let storedMode = UserDefaults.standard.string(forKey: connectionModeKey)
|
||||||
let resolvedConnectionMode: ConnectionMode = if let configMode {
|
if let configMode {
|
||||||
configMode
|
self.connectionMode = configMode
|
||||||
} else if configHasRemoteUrl {
|
} else if configHasRemoteUrl {
|
||||||
.remote
|
self.connectionMode = .remote
|
||||||
} else if let storedMode {
|
} else if let storedMode {
|
||||||
ConnectionMode(rawValue: storedMode) ?? .local
|
self.connectionMode = ConnectionMode(rawValue: storedMode) ?? .local
|
||||||
} else {
|
} else {
|
||||||
onboardingSeen ? .local : .unconfigured
|
self.connectionMode = onboardingSeen ? .local : .unconfigured
|
||||||
}
|
}
|
||||||
self.connectionMode = resolvedConnectionMode
|
|
||||||
|
|
||||||
let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
|
let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
|
||||||
let resolvedRemoteTarget = if resolvedConnectionMode == .remote,
|
if self.connectionMode == .remote,
|
||||||
storedRemoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
storedRemoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||||
let host = AppState.remoteHost(from: configRemoteUrl)
|
let host = AppState.remoteHost(from: configRemoteUrl)
|
||||||
{
|
{
|
||||||
"\(NSUserName())@\(host)"
|
self.remoteTarget = "\(NSUserName())@\(host)"
|
||||||
} else {
|
} else {
|
||||||
storedRemoteTarget
|
self.remoteTarget = storedRemoteTarget
|
||||||
}
|
}
|
||||||
self.remoteTarget = resolvedRemoteTarget
|
|
||||||
self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? ""
|
self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? ""
|
||||||
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
|
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
|
||||||
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
|
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? {
|
private static func remoteHost(from urlString: String?) -> String? {
|
||||||
guard let raw = urlString?.trimmingCharacters(in: .whitespacesAndNewlines),
|
guard let raw = urlString?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
!raw.isEmpty,
|
!raw.isEmpty,
|
||||||
@@ -357,16 +361,18 @@ final class AppState {
|
|||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
.isEmpty ?? true)
|
.isEmpty ?? true)
|
||||||
|
|
||||||
let desiredMode: ConnectionMode? = switch modeRaw {
|
let desiredMode: ConnectionMode? = {
|
||||||
case "local":
|
switch modeRaw {
|
||||||
.local
|
case "local":
|
||||||
case "remote":
|
return .local
|
||||||
.remote
|
case "remote":
|
||||||
case "unconfigured":
|
return .remote
|
||||||
.unconfigured
|
case "unconfigured":
|
||||||
default:
|
return .unconfigured
|
||||||
nil
|
default:
|
||||||
}
|
return nil
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
if let desiredMode {
|
if let desiredMode {
|
||||||
if desiredMode != self.connectionMode {
|
if desiredMode != self.connectionMode {
|
||||||
@@ -401,13 +407,14 @@ final class AppState {
|
|||||||
var gateway = root["gateway"] as? [String: Any] ?? [:]
|
var gateway = root["gateway"] as? [String: Any] ?? [:]
|
||||||
var changed = false
|
var changed = false
|
||||||
|
|
||||||
let desiredMode: String? = switch self.connectionMode {
|
let desiredMode: String?
|
||||||
|
switch self.connectionMode {
|
||||||
case .local:
|
case .local:
|
||||||
"local"
|
desiredMode = "local"
|
||||||
case .remote:
|
case .remote:
|
||||||
"remote"
|
desiredMode = "remote"
|
||||||
case .unconfigured:
|
case .unconfigured:
|
||||||
nil
|
desiredMode = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentMode = (gateway["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
let currentMode = (gateway["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ actor CameraCaptureService {
|
|||||||
deviceId: String?) -> AVCaptureDevice?
|
deviceId: String?) -> AVCaptureDevice?
|
||||||
{
|
{
|
||||||
if let deviceId, !deviceId.isEmpty {
|
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
|
return match
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -331,7 +331,7 @@ actor CameraCaptureService {
|
|||||||
|
|
||||||
private func sleepDelayMs(_ delayMs: Int) async {
|
private func sleepDelayMs(_ delayMs: Int) async {
|
||||||
guard delayMs > 0 else { return }
|
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)
|
try? await Task.sleep(nanoseconds: ns)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -100,8 +100,7 @@ enum ClawdisConfigFile {
|
|||||||
static func gatewayPassword() -> String? {
|
static func gatewayPassword() -> String? {
|
||||||
let root = self.loadDict()
|
let root = self.loadDict()
|
||||||
guard let gateway = root["gateway"] as? [String: Any],
|
guard let gateway = root["gateway"] as? [String: Any],
|
||||||
let remote = gateway["remote"] as? [String: Any]
|
let remote = gateway["remote"] as? [String: Any] else {
|
||||||
else {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return remote["password"] as? String
|
return remote["password"] as? String
|
||||||
@@ -122,4 +121,5 @@ enum ClawdisConfigFile {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -157,7 +157,7 @@ enum CommandResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func findExecutable(named name: String, searchPaths: [String]? = nil) -> String? {
|
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)
|
let candidate = (dir as NSString).appendingPathComponent(name)
|
||||||
if FileManager.default.isExecutableFile(atPath: candidate) {
|
if FileManager.default.isExecutableFile(atPath: candidate) {
|
||||||
return candidate
|
return candidate
|
||||||
|
|||||||
@@ -90,8 +90,8 @@ extension ConfigFileWatcher {
|
|||||||
private func handleEvents(
|
private func handleEvents(
|
||||||
numEvents: Int,
|
numEvents: Int,
|
||||||
eventPaths: UnsafeMutableRawPointer?,
|
eventPaths: UnsafeMutableRawPointer?,
|
||||||
eventFlags: UnsafePointer<FSEventStreamEventFlags>?)
|
eventFlags: UnsafePointer<FSEventStreamEventFlags>?
|
||||||
{
|
) {
|
||||||
guard numEvents > 0 else { return }
|
guard numEvents > 0 else { return }
|
||||||
guard eventFlags != nil else { return }
|
guard eventFlags != nil else { return }
|
||||||
guard self.matchesTarget(eventPaths: eventPaths) 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])
|
let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys])
|
||||||
guard let raw = String(data: data, encoding: .utf8) else {
|
guard let raw = String(data: data, encoding: .utf8) else {
|
||||||
throw NSError(domain: "ConfigStore", code: 1, userInfo: [
|
throw NSError(domain: "ConfigStore", code: 1, userInfo: [
|
||||||
NSLocalizedDescriptionKey: "Failed to encode config.",
|
NSLocalizedDescriptionKey: "Failed to encode config."
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
let params: [String: AnyCodable] = ["raw": AnyCodable(raw)]
|
let params: [String: AnyCodable] = ["raw": AnyCodable(raw)]
|
||||||
@@ -88,7 +88,7 @@ enum ConfigStore {
|
|||||||
timeoutMs: 10000)
|
timeoutMs: 10000)
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
static func _testSetOverrides(_ overrides: Overrides) async {
|
static func _testSetOverrides(_ overrides: Overrides) async {
|
||||||
await self.overrideStore.setOverride(overrides)
|
await self.overrideStore.setOverride(overrides)
|
||||||
}
|
}
|
||||||
@@ -96,5 +96,5 @@ enum ConfigStore {
|
|||||||
static func _testClearOverrides() async {
|
static func _testClearOverrides() async {
|
||||||
await self.overrideStore.setOverride(.init())
|
await self.overrideStore.setOverride(.init())
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
@@ -492,7 +492,7 @@ struct ConnectionsSettings: View {
|
|||||||
|
|
||||||
GroupBox("Guilds") {
|
GroupBox("Guilds") {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
ForEach(self.$store.discordGuilds) { $guild in
|
ForEach($store.discordGuilds) { $guild in
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
HStack {
|
HStack {
|
||||||
TextField("guild id or slug", text: $guild.key)
|
TextField("guild id or slug", text: $guild.key)
|
||||||
|
|||||||
@@ -172,8 +172,8 @@ struct DiscordGuildForm: Identifiable {
|
|||||||
requireMention: Bool = false,
|
requireMention: Bool = false,
|
||||||
reactionNotifications: String = "own",
|
reactionNotifications: String = "own",
|
||||||
users: String = "",
|
users: String = "",
|
||||||
channels: [DiscordGuildChannelForm] = [])
|
channels: [DiscordGuildChannelForm] = []
|
||||||
{
|
) {
|
||||||
self.key = key
|
self.key = key
|
||||||
self.slug = slug
|
self.slug = slug
|
||||||
self.requireMention = requireMention
|
self.requireMention = requireMention
|
||||||
@@ -473,16 +473,12 @@ final class ConnectionsStore {
|
|||||||
} else {
|
} else {
|
||||||
self.discordMediaMaxMb = ""
|
self.discordMediaMaxMb = ""
|
||||||
}
|
}
|
||||||
if let history = discord?["historyLimit"]?.doubleValue ?? discord?["historyLimit"]?.intValue
|
if let history = discord?["historyLimit"]?.doubleValue ?? discord?["historyLimit"]?.intValue.map(Double.init) {
|
||||||
.map(Double.init)
|
|
||||||
{
|
|
||||||
self.discordHistoryLimit = String(Int(history))
|
self.discordHistoryLimit = String(Int(history))
|
||||||
} else {
|
} else {
|
||||||
self.discordHistoryLimit = ""
|
self.discordHistoryLimit = ""
|
||||||
}
|
}
|
||||||
if let limit = discord?["textChunkLimit"]?.doubleValue ?? discord?["textChunkLimit"]?.intValue
|
if let limit = discord?["textChunkLimit"]?.doubleValue ?? discord?["textChunkLimit"]?.intValue.map(Double.init) {
|
||||||
.map(Double.init)
|
|
||||||
{
|
|
||||||
self.discordTextChunkLimit = String(Int(limit))
|
self.discordTextChunkLimit = String(Int(limit))
|
||||||
} else {
|
} else {
|
||||||
self.discordTextChunkLimit = ""
|
self.discordTextChunkLimit = ""
|
||||||
@@ -510,10 +506,9 @@ final class ConnectionsStore {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
.joined(separator: ", ") ?? ""
|
.joined(separator: ", ") ?? ""
|
||||||
let channels: [DiscordGuildChannelForm] = if let channelMap = entry["channels"]?
|
let channels: [DiscordGuildChannelForm]
|
||||||
.dictionaryValue
|
if let channelMap = entry["channels"]?.dictionaryValue {
|
||||||
{
|
channels = channelMap.map { channelKey, channelValue in
|
||||||
channelMap.map { channelKey, channelValue in
|
|
||||||
let channelEntry = channelValue.dictionaryValue ?? [:]
|
let channelEntry = channelValue.dictionaryValue ?? [:]
|
||||||
let allow = channelEntry["allow"]?.boolValue ?? true
|
let allow = channelEntry["allow"]?.boolValue ?? true
|
||||||
let channelRequireMention =
|
let channelRequireMention =
|
||||||
@@ -524,7 +519,7 @@ final class ConnectionsStore {
|
|||||||
requireMention: channelRequireMention)
|
requireMention: channelRequireMention)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
[]
|
channels = []
|
||||||
}
|
}
|
||||||
return DiscordGuildForm(
|
return DiscordGuildForm(
|
||||||
key: key,
|
key: key,
|
||||||
|
|||||||
@@ -12,13 +12,11 @@ struct ContextUsageBar: View {
|
|||||||
if match == .darkAqua { return base }
|
if match == .darkAqua { return base }
|
||||||
return base.blended(withFraction: 0.24, of: .black) ?? base
|
return base.blended(withFraction: 0.24, of: .black) ?? base
|
||||||
}
|
}
|
||||||
|
|
||||||
private static let trackFill: NSColor = .init(name: nil) { appearance in
|
private static let trackFill: NSColor = .init(name: nil) { appearance in
|
||||||
let match = appearance.bestMatch(from: [.aqua, .darkAqua])
|
let match = appearance.bestMatch(from: [.aqua, .darkAqua])
|
||||||
if match == .darkAqua { return NSColor.white.withAlphaComponent(0.14) }
|
if match == .darkAqua { return NSColor.white.withAlphaComponent(0.14) }
|
||||||
return NSColor.black.withAlphaComponent(0.12)
|
return NSColor.black.withAlphaComponent(0.12)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static let trackStroke: NSColor = .init(name: nil) { appearance in
|
private static let trackStroke: NSColor = .init(name: nil) { appearance in
|
||||||
let match = appearance.bestMatch(from: [.aqua, .darkAqua])
|
let match = appearance.bestMatch(from: [.aqua, .darkAqua])
|
||||||
if match == .darkAqua { return NSColor.white.withAlphaComponent(0.22) }
|
if match == .darkAqua { return NSColor.white.withAlphaComponent(0.22) }
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ final class ControlChannel {
|
|||||||
private(set) var state: ConnectionState = .disconnected {
|
private(set) var state: ConnectionState = .disconnected {
|
||||||
didSet {
|
didSet {
|
||||||
CanvasManager.shared.refreshDebugStatus()
|
CanvasManager.shared.refreshDebugStatus()
|
||||||
guard oldValue != self.state else { return }
|
guard oldValue != state else { return }
|
||||||
switch self.state {
|
switch state {
|
||||||
case .connected:
|
case .connected:
|
||||||
self.logger.info("control channel state -> connected")
|
self.logger.info("control channel state -> connected")
|
||||||
case .connecting:
|
case .connecting:
|
||||||
@@ -71,7 +71,6 @@ final class ControlChannel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private(set) var lastPingMs: Double?
|
private(set) var lastPingMs: Double?
|
||||||
|
|
||||||
private let logger = Logger(subsystem: "com.clawdis", category: "control")
|
private let logger = Logger(subsystem: "com.clawdis", category: "control")
|
||||||
|
|||||||
@@ -138,20 +138,17 @@ enum DeviceModelCatalog {
|
|||||||
if bundle.url(
|
if bundle.url(
|
||||||
forResource: "ios-device-identifiers",
|
forResource: "ios-device-identifiers",
|
||||||
withExtension: "json",
|
withExtension: "json",
|
||||||
subdirectory: self.resourceSubdirectory) != nil
|
subdirectory: self.resourceSubdirectory) != nil {
|
||||||
{
|
|
||||||
return bundle
|
return bundle
|
||||||
}
|
}
|
||||||
if bundle.url(
|
if bundle.url(
|
||||||
forResource: "mac-device-identifiers",
|
forResource: "mac-device-identifiers",
|
||||||
withExtension: "json",
|
withExtension: "json",
|
||||||
subdirectory: self.resourceSubdirectory) != nil
|
subdirectory: self.resourceSubdirectory) != nil {
|
||||||
{
|
|
||||||
return bundle
|
return bundle
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum NameValue: Decodable {
|
private enum NameValue: Decodable {
|
||||||
case string(String)
|
case string(String)
|
||||||
case stringArray([String])
|
case stringArray([String])
|
||||||
|
|||||||
@@ -237,9 +237,8 @@ actor GatewayConnection {
|
|||||||
guard let snapshot = self.lastSnapshot else { return (nil, nil) }
|
guard let snapshot = self.lastSnapshot else { return (nil, nil) }
|
||||||
let configPath = snapshot.snapshot.configpath?.trimmingCharacters(in: .whitespacesAndNewlines)
|
let configPath = snapshot.snapshot.configpath?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let stateDir = snapshot.snapshot.statedir?.trimmingCharacters(in: .whitespacesAndNewlines)
|
let stateDir = snapshot.snapshot.statedir?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
return (
|
return (configPath?.isEmpty == false ? configPath : nil,
|
||||||
configPath?.isEmpty == false ? configPath : nil,
|
stateDir?.isEmpty == false ? stateDir : nil)
|
||||||
stateDir?.isEmpty == false ? stateDir : nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func subscribe(bufferingNewest: Int = 100) -> AsyncStream<GatewayPush> {
|
func subscribe(bufferingNewest: Int = 100) -> AsyncStream<GatewayPush> {
|
||||||
@@ -271,9 +270,7 @@ actor GatewayConnection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func configure(url: URL, token: String?, password: String?) async {
|
private func configure(url: URL, token: String?, password: String?) async {
|
||||||
if self.client != nil, self.configuredURL == url, self.configuredToken == token,
|
if self.client != nil, self.configuredURL == url, self.configuredToken == token, self.configuredPassword == password {
|
||||||
self.configuredPassword == password
|
|
||||||
{
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if let client {
|
if let client {
|
||||||
|
|||||||
@@ -40,8 +40,8 @@ actor GatewayEndpointStore {
|
|||||||
private static func resolveGatewayPassword(
|
private static func resolveGatewayPassword(
|
||||||
isRemote: Bool,
|
isRemote: Bool,
|
||||||
root: [String: Any],
|
root: [String: Any],
|
||||||
env: [String: String]) -> String?
|
env: [String: String]
|
||||||
{
|
) -> String? {
|
||||||
let raw = env["CLAWDIS_GATEWAY_PASSWORD"] ?? ""
|
let raw = env["CLAWDIS_GATEWAY_PASSWORD"] ?? ""
|
||||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if !trimmed.isEmpty {
|
if !trimmed.isEmpty {
|
||||||
@@ -93,11 +93,7 @@ actor GatewayEndpointStore {
|
|||||||
let password = deps.password()
|
let password = deps.password()
|
||||||
switch initialMode {
|
switch initialMode {
|
||||||
case .local:
|
case .local:
|
||||||
self.state = .ready(
|
self.state = .ready(mode: .local, url: URL(string: "ws://127.0.0.1:\(port)")!, token: token, password: password)
|
||||||
mode: .local,
|
|
||||||
url: URL(string: "ws://127.0.0.1:\(port)")!,
|
|
||||||
token: token,
|
|
||||||
password: password)
|
|
||||||
case .remote:
|
case .remote:
|
||||||
self.state = .unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel")
|
self.state = .unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel")
|
||||||
case .unconfigured:
|
case .unconfigured:
|
||||||
@@ -129,22 +125,14 @@ actor GatewayEndpointStore {
|
|||||||
switch mode {
|
switch mode {
|
||||||
case .local:
|
case .local:
|
||||||
let port = self.deps.localPort()
|
let port = self.deps.localPort()
|
||||||
self.setState(.ready(
|
self.setState(.ready(mode: .local, url: URL(string: "ws://127.0.0.1:\(port)")!, token: token, password: password))
|
||||||
mode: .local,
|
|
||||||
url: URL(string: "ws://127.0.0.1:\(port)")!,
|
|
||||||
token: token,
|
|
||||||
password: password))
|
|
||||||
case .remote:
|
case .remote:
|
||||||
let port = await self.deps.remotePortIfRunning()
|
let port = await self.deps.remotePortIfRunning()
|
||||||
guard let port else {
|
guard let port else {
|
||||||
self.setState(.unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel"))
|
self.setState(.unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
self.setState(.ready(
|
self.setState(.ready(mode: .remote, url: URL(string: "ws://127.0.0.1:\(Int(port))")!, token: token, password: password))
|
||||||
mode: .remote,
|
|
||||||
url: URL(string: "ws://127.0.0.1:\(Int(port))")!,
|
|
||||||
token: token,
|
|
||||||
password: password))
|
|
||||||
case .unconfigured:
|
case .unconfigured:
|
||||||
self.setState(.unavailable(mode: .unconfigured, reason: "Gateway not configured"))
|
self.setState(.unavailable(mode: .unconfigured, reason: "Gateway not configured"))
|
||||||
}
|
}
|
||||||
@@ -225,8 +213,8 @@ extension GatewayEndpointStore {
|
|||||||
static func _testResolveGatewayPassword(
|
static func _testResolveGatewayPassword(
|
||||||
isRemote: Bool,
|
isRemote: Bool,
|
||||||
root: [String: Any],
|
root: [String: Any],
|
||||||
env: [String: String]) -> String?
|
env: [String: String]
|
||||||
{
|
) -> String? {
|
||||||
self.resolveGatewayPassword(isRemote: isRemote, root: root, env: env)
|
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] {
|
private static func gatewayProgramArguments(bundlePath: String, port: Int, bind: String) -> [String] {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
let projectRoot = CommandResolver.projectRoot()
|
let projectRoot = CommandResolver.projectRoot()
|
||||||
if let localBin = CommandResolver.projectClawdisExecutable(projectRoot: projectRoot) {
|
if let localBin = CommandResolver.projectClawdisExecutable(projectRoot: projectRoot) {
|
||||||
return [localBin, "gateway", "--port", "\(port)", "--bind", bind]
|
return [localBin, "gateway", "--port", "\(port)", "--bind", bind]
|
||||||
@@ -38,7 +38,7 @@ enum GatewayLaunchAgentManager {
|
|||||||
subcommand: "gateway",
|
subcommand: "gateway",
|
||||||
extraArgs: ["--port", "\(port)", "--bind", bind])
|
extraArgs: ["--port", "\(port)", "--bind", bind])
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath)
|
let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath)
|
||||||
return [gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind]
|
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? {
|
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
|
||||||
if enabled {
|
if enabled {
|
||||||
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(self.legacyGatewayLaunchdLabel)"])
|
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(legacyGatewayLaunchdLabel)"])
|
||||||
try? FileManager.default.removeItem(at: self.legacyPlistURL)
|
try? FileManager.default.removeItem(at: self.legacyPlistURL)
|
||||||
let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath)
|
let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath)
|
||||||
guard FileManager.default.isExecutableFile(atPath: gatewayBin) else {
|
guard FileManager.default.isExecutableFile(atPath: gatewayBin) else {
|
||||||
|
|||||||
@@ -151,16 +151,16 @@ struct GeneralSettings: View {
|
|||||||
private func requestLocationAuthorization(mode: ClawdisLocationMode) async -> Bool {
|
private func requestLocationAuthorization(mode: ClawdisLocationMode) async -> Bool {
|
||||||
guard mode != .off else { return true }
|
guard mode != .off else { return true }
|
||||||
let status = CLLocationManager.authorizationStatus()
|
let status = CLLocationManager.authorizationStatus()
|
||||||
if status == .authorizedAlways || status == .authorized {
|
if status == .authorizedAlways || status == .authorizedWhenInUse {
|
||||||
if mode == .always, status != .authorizedAlways {
|
if mode == .always && status != .authorizedAlways {
|
||||||
let updated = await LocationPermissionRequester.shared.request(always: true)
|
let updated = await LocationPermissionRequester.shared.request(always: true)
|
||||||
return updated == .authorizedAlways || updated == .authorized
|
return updated == .authorizedAlways || updated == .authorizedWhenInUse
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
let updated = await LocationPermissionRequester.shared.request(always: mode == .always)
|
let updated = await LocationPermissionRequester.shared.request(always: mode == .always)
|
||||||
switch updated {
|
switch updated {
|
||||||
case .authorizedAlways, .authorized:
|
case .authorizedAlways, .authorizedWhenInUse:
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ enum LaunchAgentManager {
|
|||||||
FileManager.default.homeDirectoryForCurrentUser
|
FileManager.default.homeDirectoryForCurrentUser
|
||||||
.appendingPathComponent("Library/LaunchAgents/com.clawdis.mac.plist")
|
.appendingPathComponent("Library/LaunchAgents/com.clawdis.mac.plist")
|
||||||
}
|
}
|
||||||
|
|
||||||
private static var legacyPlistURL: URL {
|
private static var legacyPlistURL: URL {
|
||||||
FileManager.default.homeDirectoryForCurrentUser
|
FileManager.default.homeDirectoryForCurrentUser
|
||||||
.appendingPathComponent("Library/LaunchAgents/\(legacyLaunchdLabel).plist")
|
.appendingPathComponent("Library/LaunchAgents/\(legacyLaunchdLabel).plist")
|
||||||
@@ -20,7 +19,7 @@ enum LaunchAgentManager {
|
|||||||
|
|
||||||
static func set(enabled: Bool, bundlePath: String) async {
|
static func set(enabled: Bool, bundlePath: String) async {
|
||||||
if enabled {
|
if enabled {
|
||||||
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(self.legacyLaunchdLabel)"])
|
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(legacyLaunchdLabel)"])
|
||||||
try? FileManager.default.removeItem(at: self.legacyPlistURL)
|
try? FileManager.default.removeItem(at: self.legacyPlistURL)
|
||||||
self.writePlist(bundlePath: bundlePath)
|
self.writePlist(bundlePath: bundlePath)
|
||||||
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(launchdLabel)"])
|
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(launchdLabel)"])
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Foundation
|
|
||||||
@_exported import Logging
|
@_exported import Logging
|
||||||
import os
|
import Foundation
|
||||||
import OSLog
|
import OSLog
|
||||||
|
import os
|
||||||
|
|
||||||
typealias Logger = Logging.Logger
|
typealias Logger = Logging.Logger
|
||||||
|
|
||||||
@@ -65,15 +65,15 @@ enum ClawdisLogging {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
static func bootstrapIfNeeded() {
|
static func bootstrapIfNeeded() {
|
||||||
_ = self.didBootstrap
|
_ = Self.didBootstrap
|
||||||
}
|
}
|
||||||
|
|
||||||
static func makeLabel(subsystem: String, category: String) -> String {
|
static func makeLabel(subsystem: String, category: String) -> String {
|
||||||
"\(subsystem)\(self.labelSeparator)\(category)"
|
"\(subsystem)\(Self.labelSeparator)\(category)"
|
||||||
}
|
}
|
||||||
|
|
||||||
static func parseLabel(_ label: String) -> (String, String) {
|
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)
|
return ("com.clawdis", label)
|
||||||
}
|
}
|
||||||
let subsystem = String(label[..<range.lowerBound])
|
let subsystem = String(label[..<range.lowerBound])
|
||||||
@@ -91,7 +91,7 @@ extension Logging.Logger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension Logger.Message.StringInterpolation {
|
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))
|
self.appendInterpolation(String(describing: value))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,15 +132,15 @@ struct ClawdisOSLogHandler: LogHandler {
|
|||||||
private static func osLogType(for level: Logger.Level) -> OSLogType {
|
private static func osLogType(for level: Logger.Level) -> OSLogType {
|
||||||
switch level {
|
switch level {
|
||||||
case .trace, .debug:
|
case .trace, .debug:
|
||||||
.debug
|
return .debug
|
||||||
case .info, .notice:
|
case .info, .notice:
|
||||||
.info
|
return .info
|
||||||
case .warning:
|
case .warning:
|
||||||
.default
|
return .default
|
||||||
case .error:
|
case .error:
|
||||||
.error
|
return .error
|
||||||
case .critical:
|
case .critical:
|
||||||
.fault
|
return .fault
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +156,7 @@ struct ClawdisOSLogHandler: LogHandler {
|
|||||||
guard !metadata.isEmpty else { return message.description }
|
guard !metadata.isEmpty else { return message.description }
|
||||||
let meta = metadata
|
let meta = metadata
|
||||||
.sorted(by: { $0.key < $1.key })
|
.sorted(by: { $0.key < $1.key })
|
||||||
.map { "\($0.key)=\(self.stringify($0.value))" }
|
.map { "\($0.key)=\(stringify($0.value))" }
|
||||||
.joined(separator: " ")
|
.joined(separator: " ")
|
||||||
return "\(message.description) [\(meta)]"
|
return "\(message.description) [\(meta)]"
|
||||||
}
|
}
|
||||||
@@ -168,9 +168,9 @@ struct ClawdisOSLogHandler: LogHandler {
|
|||||||
case let .stringConvertible(value):
|
case let .stringConvertible(value):
|
||||||
String(describing: value)
|
String(describing: value)
|
||||||
case let .array(values):
|
case let .array(values):
|
||||||
"[" + values.map { self.stringify($0) }.joined(separator: ",") + "]"
|
"[" + values.map { stringify($0) }.joined(separator: ",") + "]"
|
||||||
case let .dictionary(entries):
|
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):
|
case let .stringConvertible(value):
|
||||||
String(describing: value)
|
String(describing: value)
|
||||||
case let .array(values):
|
case let .array(values):
|
||||||
"[" + values.map { self.stringify($0) }.joined(separator: ",") + "]"
|
"[" + values.map { stringify($0) }.joined(separator: ",") + "]"
|
||||||
case let .dictionary(entries):
|
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
|
@MainActor
|
||||||
private static func buildAndSaveBrowserEnabled(_ enabled: Bool) async -> (Bool, ()) {
|
private static func buildAndSaveBrowserEnabled(_ enabled: Bool) async -> (Bool,()) {
|
||||||
var root = await ConfigStore.load()
|
var root = await ConfigStore.load()
|
||||||
var browser = root["browser"] as? [String: Any] ?? [:]
|
var browser = root["browser"] as? [String: Any] ?? [:]
|
||||||
browser["enabled"] = enabled
|
browser["enabled"] = enabled
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ final class HighlightedMenuItemHostView: NSView {
|
|||||||
self.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height))
|
self.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height))
|
||||||
self.invalidateIntrinsicContentSize()
|
self.invalidateIntrinsicContentSize()
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct MenuHostedHighlightedItem: NSViewRepresentable {
|
struct MenuHostedHighlightedItem: NSViewRepresentable {
|
||||||
|
|||||||
@@ -435,7 +435,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
compact.representedObject = row.key
|
compact.representedObject = row.key
|
||||||
menu.addItem(compact)
|
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: "")
|
let del = NSMenuItem(title: "Delete Session", action: #selector(self.deleteSession(_:)), keyEquivalent: "")
|
||||||
del.target = self
|
del.target = self
|
||||||
del.representedObject = row.key
|
del.representedObject = row.key
|
||||||
@@ -541,14 +541,12 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
menu.addItem(self.makeNodeDetailItem(label: "Paired", value: entry.isPaired ? "Yes" : "No"))
|
menu.addItem(self.makeNodeDetailItem(label: "Paired", value: entry.isPaired ? "Yes" : "No"))
|
||||||
|
|
||||||
if let caps = entry.caps?.filter({ !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }),
|
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: ", ")))
|
menu.addItem(self.makeNodeCopyItem(label: "Caps", value: caps.joined(separator: ", ")))
|
||||||
}
|
}
|
||||||
|
|
||||||
if let commands = entry.commands?.filter({ !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }),
|
if let commands = entry.commands?.filter({ !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }),
|
||||||
!commands.isEmpty
|
!commands.isEmpty {
|
||||||
{
|
|
||||||
menu.addItem(self.makeNodeMultilineItem(
|
menu.addItem(self.makeNodeMultilineItem(
|
||||||
label: "Commands",
|
label: "Commands",
|
||||||
value: commands.joined(separator: ", "),
|
value: commands.joined(separator: ", "),
|
||||||
@@ -591,7 +589,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
}
|
}
|
||||||
return trimmed
|
return trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc
|
@objc
|
||||||
private func patchThinking(_ sender: NSMenuItem) {
|
private func patchThinking(_ sender: NSMenuItem) {
|
||||||
guard let dict = sender.representedObject as? [String: Any],
|
guard let dict = sender.representedObject as? [String: Any],
|
||||||
@@ -773,7 +770,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func sortedNodeEntries() -> [NodeInfo] {
|
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
|
return entries.sorted { lhs, rhs in
|
||||||
if lhs.isConnected != rhs.isConnected { return lhs.isConnected }
|
if lhs.isConnected != rhs.isConnected { return lhs.isConnected }
|
||||||
if lhs.isPaired != rhs.isPaired { return lhs.isPaired }
|
if lhs.isPaired != rhs.isPaired { return lhs.isPaired }
|
||||||
@@ -784,6 +781,8 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Views
|
// MARK: - Views
|
||||||
|
|
||||||
private func makeHostedView(rootView: AnyView, width: CGFloat, highlighted: Bool) -> NSView {
|
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
|
import Foundation
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class MacNodeLocationService: NSObject {
|
final class MacNodeLocationService: NSObject, CLLocationManagerDelegate {
|
||||||
enum Error: Swift.Error {
|
enum Error: Swift.Error {
|
||||||
case timeout
|
case timeout
|
||||||
case unavailable
|
case unavailable
|
||||||
@@ -47,8 +47,10 @@ final class MacNodeLocationService: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.manager.desiredAccuracy = Self.accuracyValue(desiredAccuracy)
|
self.manager.desiredAccuracy = Self.accuracyValue(desiredAccuracy)
|
||||||
let timeout = max(0, timeoutMs ?? 10000)
|
let timeout = max(0, timeoutMs ?? 10_000)
|
||||||
return try await self.requestLocationWithTimeout(timeoutMs: timeout)
|
return try await self.withTimeout(timeoutMs: timeout) {
|
||||||
|
try await self.requestLocation()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func requestLocation() async throws -> CLLocation {
|
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 {
|
if timeoutMs == 0 {
|
||||||
return try await self.requestLocation()
|
return try await operation()
|
||||||
}
|
}
|
||||||
|
|
||||||
let timeoutNs = UInt64(timeoutMs) * 1_000_000
|
return try await withThrowingTaskGroup(of: T.self) { group in
|
||||||
return try await withCheckedThrowingContinuation { continuation in
|
group.addTask { try await operation() }
|
||||||
let lock = NSLock()
|
group.addTask {
|
||||||
var didResume = false
|
try await Task.sleep(nanoseconds: UInt64(timeoutMs) * 1_000_000)
|
||||||
|
throw Error.timeout
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
let result = try await group.next()!
|
||||||
|
group.cancelAll()
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func accuracyValue(_ accuracy: ClawdisLocationAccuracy) -> CLLocationAccuracy {
|
private static func accuracyValue(_ accuracy: ClawdisLocationAccuracy) -> CLLocationAccuracy {
|
||||||
switch accuracy {
|
switch accuracy {
|
||||||
case .coarse:
|
case .coarse:
|
||||||
kCLLocationAccuracyKilometer
|
return kCLLocationAccuracyKilometer
|
||||||
case .balanced:
|
case .balanced:
|
||||||
kCLLocationAccuracyHundredMeters
|
return kCLLocationAccuracyHundredMeters
|
||||||
case .precise:
|
case .precise:
|
||||||
kCLLocationAccuracyBest
|
return kCLLocationAccuracyBest
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
// MARK: - CLLocationManagerDelegate (nonisolated for Swift 6 compatibility)
|
||||||
guard let cont = self.locationContinuation else { return }
|
|
||||||
self.locationContinuation = nil
|
nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||||
if let latest = locations.last {
|
Task { @MainActor in
|
||||||
cont.resume(returning: latest)
|
guard let cont = self.locationContinuation else { return }
|
||||||
} else {
|
self.locationContinuation = nil
|
||||||
cont.resume(throwing: Error.unavailable)
|
if let latest = locations.last {
|
||||||
|
cont.resume(returning: latest)
|
||||||
|
} else {
|
||||||
|
cont.resume(throwing: Error.unavailable)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func locationManager(_ manager: CLLocationManager, didFailWithError error: Swift.Error) {
|
nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Swift.Error) {
|
||||||
guard let cont = self.locationContinuation else { return }
|
let errorCopy = error // Capture error for Sendable compliance
|
||||||
self.locationContinuation = nil
|
Task { @MainActor in
|
||||||
cont.resume(throwing: error)
|
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)) ??
|
let params = (try? Self.decodeParams(ClawdisCameraSnapParams.self, from: req.paramsJSON)) ??
|
||||||
ClawdisCameraSnapParams()
|
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(
|
let res = try await self.cameraCapture.snap(
|
||||||
facing: CameraFacing(rawValue: params.facing?.rawValue ?? "") ?? .front,
|
facing: CameraFacing(rawValue: params.facing?.rawValue ?? "") ?? .front,
|
||||||
maxWidth: params.maxWidth,
|
maxWidth: params.maxWidth,
|
||||||
@@ -184,7 +184,7 @@ actor MacNodeRuntime {
|
|||||||
let desired = params.desiredAccuracy ??
|
let desired = params.desiredAccuracy ??
|
||||||
(Self.locationPreciseEnabled() ? .precise : .balanced)
|
(Self.locationPreciseEnabled() ? .precise : .balanced)
|
||||||
let status = await self.locationService.authorizationStatus()
|
let status = await self.locationService.authorizationStatus()
|
||||||
if status != .authorizedAlways, status != .authorized {
|
if status != .authorizedAlways && status != .authorizedWhenInUse {
|
||||||
return BridgeInvokeResponse(
|
return BridgeInvokeResponse(
|
||||||
id: req.id,
|
id: req.id,
|
||||||
ok: false,
|
ok: false,
|
||||||
|
|||||||
@@ -611,7 +611,7 @@ final class NodePairingApprovalPrompter {
|
|||||||
private func updatePendingCounts() {
|
private func updatePendingCounts() {
|
||||||
// Keep a cheap observable summary for the menu bar status line.
|
// Keep a cheap observable summary for the menu bar status line.
|
||||||
self.pendingCount = self.queue.count
|
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 {
|
private func reconcileOnce(timeoutMs: Double) async {
|
||||||
|
|||||||
@@ -110,8 +110,8 @@ struct NodeMenuEntryFormatter {
|
|||||||
guard !trimmed.isEmpty else { return trimmed }
|
guard !trimmed.isEmpty else { return trimmed }
|
||||||
if let range = trimmed.range(
|
if let range = trimmed.range(
|
||||||
of: #"\s*\([^)]*\d[^)]*\)$"#,
|
of: #"\s*\([^)]*\d[^)]*\)$"#,
|
||||||
options: .regularExpression)
|
options: .regularExpression
|
||||||
{
|
) {
|
||||||
return String(trimmed[..<range.lowerBound])
|
return String(trimmed[..<range.lowerBound])
|
||||||
}
|
}
|
||||||
return trimmed
|
return trimmed
|
||||||
@@ -227,6 +227,7 @@ struct NodeMenuRowView: View {
|
|||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
}
|
}
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
.padding(.leading, 18)
|
.padding(.leading, 18)
|
||||||
|
|||||||
@@ -139,7 +139,6 @@ struct OnboardingView: View {
|
|||||||
var isWizardBlocking: Bool {
|
var isWizardBlocking: Bool {
|
||||||
self.activePageIndex == self.wizardPageIndex && !self.onboardingWizard.isComplete
|
self.activePageIndex == self.wizardPageIndex && !self.onboardingWizard.isComplete
|
||||||
}
|
}
|
||||||
|
|
||||||
var canAdvance: Bool { !self.isWizardBlocking }
|
var canAdvance: Bool { !self.isWizardBlocking }
|
||||||
var devLinkCommand: String {
|
var devLinkCommand: String {
|
||||||
let bundlePath = Bundle.main.bundlePath
|
let bundlePath = Bundle.main.bundlePath
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ extension OnboardingView {
|
|||||||
|
|
||||||
var navigationBar: some View {
|
var navigationBar: some View {
|
||||||
let wizardLockIndex = self.wizardPageOrderIndex
|
let wizardLockIndex = self.wizardPageOrderIndex
|
||||||
return HStack(spacing: 20) {
|
HStack(spacing: 20) {
|
||||||
ZStack(alignment: .leading) {
|
ZStack(alignment: .leading) {
|
||||||
Button(action: {}, label: {
|
Button(action: {}, label: {
|
||||||
Label("Back", systemImage: "chevron.left").labelStyle(.iconOnly)
|
Label("Back", systemImage: "chevron.left").labelStyle(.iconOnly)
|
||||||
@@ -112,8 +112,7 @@ extension OnboardingView {
|
|||||||
|
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
ForEach(0..<self.pageCount, id: \.self) { index in
|
ForEach(0..<self.pageCount, id: \.self) { index in
|
||||||
let isLocked = wizardLockIndex != nil && !self.onboardingWizard
|
let isLocked = wizardLockIndex != nil && !self.onboardingWizard.isComplete && index > (wizardLockIndex ?? 0)
|
||||||
.isComplete && index > (wizardLockIndex ?? 0)
|
|
||||||
Button {
|
Button {
|
||||||
withAnimation { self.currentPage = index }
|
withAnimation { self.currentPage = index }
|
||||||
} label: {
|
} label: {
|
||||||
|
|||||||
@@ -444,7 +444,7 @@ extension OnboardingView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func permissionsPage() -> some View {
|
func permissionsPage() -> some View {
|
||||||
self.onboardingPage {
|
return self.onboardingPage {
|
||||||
Text("Grant permissions")
|
Text("Grant permissions")
|
||||||
.font(.largeTitle.weight(.semibold))
|
.font(.largeTitle.weight(.semibold))
|
||||||
Text("These macOS permissions let Clawdis automate apps and capture context on this Mac.")
|
Text("These macOS permissions let Clawdis automate apps and capture context on this Mac.")
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import ClawdisProtocol
|
|
||||||
import Observation
|
import Observation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@@ -45,15 +44,15 @@ private struct OnboardingWizardCardContent: View {
|
|||||||
|
|
||||||
private var state: CardState {
|
private var state: CardState {
|
||||||
if let error = wizard.errorMessage { return .error(error) }
|
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 let step = wizard.currentStep { return .step(step) }
|
||||||
if self.wizard.isComplete { return .complete }
|
if wizard.isComplete { return .complete }
|
||||||
return .waiting
|
return .waiting
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
switch self.state {
|
switch state {
|
||||||
case let .error(error):
|
case .error(let error):
|
||||||
Text("Wizard error")
|
Text("Wizard error")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
Text(error)
|
Text(error)
|
||||||
@@ -61,11 +60,11 @@ private struct OnboardingWizardCardContent: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
Button("Retry") {
|
Button("Retry") {
|
||||||
self.wizard.reset()
|
wizard.reset()
|
||||||
Task {
|
Task {
|
||||||
await self.wizard.startIfNeeded(
|
await wizard.startIfNeeded(
|
||||||
mode: self.mode,
|
mode: mode,
|
||||||
workspace: self.workspacePath.isEmpty ? nil : self.workspacePath)
|
workspace: workspacePath.isEmpty ? nil : workspacePath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
@@ -75,12 +74,12 @@ private struct OnboardingWizardCardContent: View {
|
|||||||
Text("Starting wizard…")
|
Text("Starting wizard…")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
case let .step(step):
|
case .step(let step):
|
||||||
OnboardingWizardStepView(
|
OnboardingWizardStepView(
|
||||||
step: step,
|
step: step,
|
||||||
isSubmitting: self.wizard.isSubmitting)
|
isSubmitting: wizard.isSubmitting)
|
||||||
{ value in
|
{ value in
|
||||||
Task { await self.wizard.submit(step: step, value: value) }
|
Task { await wizard.submit(step: step, value: value) }
|
||||||
}
|
}
|
||||||
.id(step.id)
|
.id(step.id)
|
||||||
case .complete:
|
case .complete:
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ extension OnboardingView {
|
|||||||
do {
|
do {
|
||||||
try await ConfigStore.save(root)
|
try await ConfigStore.save(root)
|
||||||
return (true, nil)
|
return (true, nil)
|
||||||
} catch {
|
} catch let error {
|
||||||
let errorMessage = "Failed to save config: \(error.localizedDescription)"
|
let errorMessage = "Failed to save config: \(error.localizedDescription)"
|
||||||
return (false, errorMessage)
|
return (false, errorMessage)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,26 @@ import Observation
|
|||||||
import OSLog
|
import OSLog
|
||||||
import SwiftUI
|
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 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
|
@MainActor
|
||||||
@Observable
|
@Observable
|
||||||
@@ -45,7 +62,7 @@ final class OnboardingWizardModel {
|
|||||||
let res: WizardStartResult = try await GatewayConnection.shared.requestDecoded(
|
let res: WizardStartResult = try await GatewayConnection.shared.requestDecoded(
|
||||||
method: .wizardStart,
|
method: .wizardStart,
|
||||||
params: params)
|
params: params)
|
||||||
self.applyStartResult(res)
|
applyStartResult(res)
|
||||||
} catch {
|
} catch {
|
||||||
self.status = "error"
|
self.status = "error"
|
||||||
self.errorMessage = error.localizedDescription
|
self.errorMessage = error.localizedDescription
|
||||||
@@ -69,7 +86,7 @@ final class OnboardingWizardModel {
|
|||||||
let res: WizardNextResult = try await GatewayConnection.shared.requestDecoded(
|
let res: WizardNextResult = try await GatewayConnection.shared.requestDecoded(
|
||||||
method: .wizardNext,
|
method: .wizardNext,
|
||||||
params: params)
|
params: params)
|
||||||
self.applyNextResult(res)
|
applyNextResult(res)
|
||||||
} catch {
|
} catch {
|
||||||
self.status = "error"
|
self.status = "error"
|
||||||
self.errorMessage = error.localizedDescription
|
self.errorMessage = error.localizedDescription
|
||||||
@@ -83,7 +100,7 @@ final class OnboardingWizardModel {
|
|||||||
let res: WizardStatusResult = try await GatewayConnection.shared.requestDecoded(
|
let res: WizardStatusResult = try await GatewayConnection.shared.requestDecoded(
|
||||||
method: .wizardCancel,
|
method: .wizardCancel,
|
||||||
params: ["sessionId": AnyCodable(sessionId)])
|
params: ["sessionId": AnyCodable(sessionId)])
|
||||||
self.applyStatusResult(res)
|
applyStatusResult(res)
|
||||||
} catch {
|
} catch {
|
||||||
self.status = "error"
|
self.status = "error"
|
||||||
self.errorMessage = error.localizedDescription
|
self.errorMessage = error.localizedDescription
|
||||||
@@ -105,8 +122,7 @@ final class OnboardingWizardModel {
|
|||||||
self.currentStep = decodeWizardStep(res.step)
|
self.currentStep = decodeWizardStep(res.step)
|
||||||
if res.done { self.currentStep = nil }
|
if res.done { self.currentStep = nil }
|
||||||
if res.done || anyCodableStringValue(res.status) == "done" || anyCodableStringValue(res.status) == "cancelled"
|
if res.done || anyCodableStringValue(res.status) == "done" || anyCodableStringValue(res.status) == "cancelled"
|
||||||
|| anyCodableStringValue(res.status) == "error"
|
|| anyCodableStringValue(res.status) == "error" {
|
||||||
{
|
|
||||||
self.sessionId = nil
|
self.sessionId = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,7 +161,8 @@ struct OnboardingWizardStepView: View {
|
|||||||
let initialMulti = Set(
|
let initialMulti = Set(
|
||||||
options.filter { option in
|
options.filter { option in
|
||||||
anyCodableArray(step.initialvalue).contains { anyCodableEqual($0, option.option.value) }
|
anyCodableArray(step.initialvalue).contains { anyCodableEqual($0, option.option.value) }
|
||||||
}.map(\.index))
|
}.map { $0.index }
|
||||||
|
)
|
||||||
|
|
||||||
_textValue = State(initialValue: initialText)
|
_textValue = State(initialValue: initialText)
|
||||||
_confirmValue = State(initialValue: initialConfirm)
|
_confirmValue = State(initialValue: initialConfirm)
|
||||||
@@ -166,18 +183,18 @@ struct OnboardingWizardStepView: View {
|
|||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
switch wizardStepType(self.step) {
|
switch wizardStepType(step) {
|
||||||
case "note":
|
case "note":
|
||||||
EmptyView()
|
EmptyView()
|
||||||
case "text":
|
case "text":
|
||||||
self.textField
|
textField
|
||||||
case "confirm":
|
case "confirm":
|
||||||
Toggle("", isOn: self.$confirmValue)
|
Toggle("", isOn: $confirmValue)
|
||||||
.toggleStyle(.switch)
|
.toggleStyle(.switch)
|
||||||
case "select":
|
case "select":
|
||||||
self.selectOptions
|
selectOptions
|
||||||
case "multiselect":
|
case "multiselect":
|
||||||
self.multiselectOptions
|
multiselectOptions
|
||||||
case "progress":
|
case "progress":
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.controlSize(.small)
|
.controlSize(.small)
|
||||||
@@ -188,25 +205,25 @@ struct OnboardingWizardStepView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
Button(action: self.submit) {
|
Button(action: submit) {
|
||||||
Text(wizardStepType(self.step) == "action" ? "Run" : "Continue")
|
Text(wizardStepType(step) == "action" ? "Run" : "Continue")
|
||||||
.frame(minWidth: 120)
|
.frame(minWidth: 120)
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.disabled(self.isSubmitting || self.isBlocked)
|
.disabled(isSubmitting || isBlocked)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var textField: some View {
|
private var textField: some View {
|
||||||
let isSensitive = self.step.sensitive == true
|
let isSensitive = step.sensitive == true
|
||||||
if isSensitive {
|
if isSensitive {
|
||||||
SecureField(self.step.placeholder ?? "", text: self.$textValue)
|
SecureField(step.placeholder ?? "", text: $textValue)
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
.frame(maxWidth: 360)
|
.frame(maxWidth: 360)
|
||||||
} else {
|
} else {
|
||||||
TextField(self.step.placeholder ?? "", text: self.$textValue)
|
TextField(step.placeholder ?? "", text: $textValue)
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
.frame(maxWidth: 360)
|
.frame(maxWidth: 360)
|
||||||
}
|
}
|
||||||
@@ -214,21 +231,33 @@ struct OnboardingWizardStepView: View {
|
|||||||
|
|
||||||
private var selectOptions: some View {
|
private var selectOptions: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
ForEach(self.optionItems) { item in
|
ForEach(optionItems) { item in
|
||||||
WizardOptionRow(
|
Button {
|
||||||
item: item,
|
selectedIndex = item.index
|
||||||
isSelected: self.selectedIndex == item.index,
|
} label: {
|
||||||
onSelect: {
|
HStack(alignment: .top, spacing: 8) {
|
||||||
self.selectedIndex = item.index
|
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 {
|
private var multiselectOptions: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
ForEach(self.optionItems) { item in
|
ForEach(optionItems) { item in
|
||||||
Toggle(isOn: self.bindingForOption(item)) {
|
Toggle(isOn: bindingForOption(item)) {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(item.option.label)
|
Text(item.option.label)
|
||||||
if let hint = item.option.hint, !hint.isEmpty {
|
if let hint = item.option.hint, !hint.isEmpty {
|
||||||
@@ -244,88 +273,56 @@ struct OnboardingWizardStepView: View {
|
|||||||
|
|
||||||
private func bindingForOption(_ item: WizardOptionItem) -> Binding<Bool> {
|
private func bindingForOption(_ item: WizardOptionItem) -> Binding<Bool> {
|
||||||
Binding(get: {
|
Binding(get: {
|
||||||
self.selectedIndices.contains(item.index)
|
selectedIndices.contains(item.index)
|
||||||
}, set: { newValue in
|
}, set: { newValue in
|
||||||
if newValue {
|
if newValue {
|
||||||
self.selectedIndices.insert(item.index)
|
selectedIndices.insert(item.index)
|
||||||
} else {
|
} else {
|
||||||
self.selectedIndices.remove(item.index)
|
selectedIndices.remove(item.index)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private var isBlocked: Bool {
|
private var isBlocked: Bool {
|
||||||
let type = wizardStepType(step)
|
let type = wizardStepType(step)
|
||||||
if type == "select" { return self.optionItems.isEmpty }
|
if type == "select" { return optionItems.isEmpty }
|
||||||
if type == "multiselect" { return self.optionItems.isEmpty }
|
if type == "multiselect" { return optionItems.isEmpty }
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
private func submit() {
|
private func submit() {
|
||||||
switch wizardStepType(self.step) {
|
switch wizardStepType(step) {
|
||||||
case "note", "progress":
|
case "note", "progress":
|
||||||
self.onSubmit(nil)
|
onSubmit(nil)
|
||||||
case "text":
|
case "text":
|
||||||
self.onSubmit(AnyCodable(self.textValue))
|
onSubmit(AnyCodable(textValue))
|
||||||
case "confirm":
|
case "confirm":
|
||||||
self.onSubmit(AnyCodable(self.confirmValue))
|
onSubmit(AnyCodable(confirmValue))
|
||||||
case "select":
|
case "select":
|
||||||
guard self.optionItems.indices.contains(self.selectedIndex) else {
|
guard optionItems.indices.contains(selectedIndex) else {
|
||||||
self.onSubmit(nil)
|
onSubmit(nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let option = self.optionItems[self.selectedIndex].option
|
let option = optionItems[selectedIndex].option
|
||||||
self.onSubmit(self.gatewayValue(option.value, fallback: option.label))
|
onSubmit(bridgeToLocal(option.value) ?? AnyCodable(option.label))
|
||||||
case "multiselect":
|
case "multiselect":
|
||||||
let values = self.optionItems
|
let values = optionItems
|
||||||
.filter { self.selectedIndices.contains($0.index) }
|
.filter { selectedIndices.contains($0.index) }
|
||||||
.map { self.gatewayValue($0.option.value, fallback: $0.option.label) }
|
.map { bridgeToLocal($0.option.value) ?? AnyCodable($0.option.label) }
|
||||||
self.onSubmit(AnyCodable(values))
|
onSubmit(AnyCodable(values))
|
||||||
case "action":
|
case "action":
|
||||||
self.onSubmit(AnyCodable(true))
|
onSubmit(AnyCodable(true))
|
||||||
default:
|
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 {
|
private struct WizardOptionItem: Identifiable {
|
||||||
let index: Int
|
let index: Int
|
||||||
let option: WizardOption
|
let option: WizardOption
|
||||||
|
|
||||||
var id: Int { self.index }
|
var id: Int { 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct WizardOption {
|
private struct WizardOption {
|
||||||
@@ -362,15 +359,15 @@ private func wizardStepType(_ step: WizardStep) -> String {
|
|||||||
private func anyCodableString(_ value: ProtocolAnyCodable?) -> String {
|
private func anyCodableString(_ value: ProtocolAnyCodable?) -> String {
|
||||||
switch value?.value {
|
switch value?.value {
|
||||||
case let string as String:
|
case let string as String:
|
||||||
string
|
return string
|
||||||
case let int as Int:
|
case let int as Int:
|
||||||
String(int)
|
return String(int)
|
||||||
case let double as Double:
|
case let double as Double:
|
||||||
String(double)
|
return String(double)
|
||||||
case let bool as Bool:
|
case let bool as Bool:
|
||||||
bool ? "true" : "false"
|
return bool ? "true" : "false"
|
||||||
default:
|
default:
|
||||||
""
|
return ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,44 +378,44 @@ private func anyCodableStringValue(_ value: ProtocolAnyCodable?) -> String? {
|
|||||||
private func anyCodableBool(_ value: ProtocolAnyCodable?) -> Bool {
|
private func anyCodableBool(_ value: ProtocolAnyCodable?) -> Bool {
|
||||||
switch value?.value {
|
switch value?.value {
|
||||||
case let bool as Bool:
|
case let bool as Bool:
|
||||||
bool
|
return bool
|
||||||
case let string as String:
|
case let string as String:
|
||||||
string.lowercased() == "true"
|
return string.lowercased() == "true"
|
||||||
default:
|
default:
|
||||||
false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func anyCodableArray(_ value: ProtocolAnyCodable?) -> [ProtocolAnyCodable] {
|
private func anyCodableArray(_ value: ProtocolAnyCodable?) -> [ProtocolAnyCodable] {
|
||||||
switch value?.value {
|
switch value?.value {
|
||||||
case let arr as [ProtocolAnyCodable]:
|
case let arr as [ProtocolAnyCodable]:
|
||||||
arr
|
return arr
|
||||||
case let arr as [Any]:
|
case let arr as [Any]:
|
||||||
arr.map { ProtocolAnyCodable($0) }
|
return arr.map { ProtocolAnyCodable($0) }
|
||||||
default:
|
default:
|
||||||
[]
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func anyCodableEqual(_ lhs: ProtocolAnyCodable?, _ rhs: ProtocolAnyCodable?) -> Bool {
|
private func anyCodableEqual(_ lhs: ProtocolAnyCodable?, _ rhs: ProtocolAnyCodable?) -> Bool {
|
||||||
switch (lhs?.value, rhs?.value) {
|
switch (lhs?.value, rhs?.value) {
|
||||||
case let (l as String, r as String):
|
case let (l as String, r as String):
|
||||||
l == r
|
return l == r
|
||||||
case let (l as Int, r as Int):
|
case let (l as Int, r as Int):
|
||||||
l == r
|
return l == r
|
||||||
case let (l as Double, r as Double):
|
case let (l as Double, r as Double):
|
||||||
l == r
|
return l == r
|
||||||
case let (l as Bool, r as Bool):
|
case let (l as Bool, r as Bool):
|
||||||
l == r
|
return l == r
|
||||||
case let (l as String, r as Int):
|
case let (l as String, r as Int):
|
||||||
l == String(r)
|
return l == String(r)
|
||||||
case let (l as Int, r as String):
|
case let (l as Int, r as String):
|
||||||
String(l) == r
|
return String(l) == r
|
||||||
case let (l as String, r as Double):
|
case let (l as String, r as Double):
|
||||||
l == String(r)
|
return l == String(r)
|
||||||
case let (l as Double, r as String):
|
case let (l as Double, r as String):
|
||||||
String(l) == r
|
return String(l) == r
|
||||||
default:
|
default:
|
||||||
false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import Security
|
||||||
import os
|
import os
|
||||||
import PeekabooAutomationKit
|
import PeekabooAutomationKit
|
||||||
import PeekabooBridge
|
import PeekabooBridge
|
||||||
import PeekabooFoundation
|
import PeekabooFoundation
|
||||||
import Security
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class PeekabooBridgeHostCoordinator {
|
final class PeekabooBridgeHostCoordinator {
|
||||||
@@ -80,7 +80,7 @@ final class PeekabooBridgeHostCoordinator {
|
|||||||
staticCode,
|
staticCode,
|
||||||
SecCSFlags(rawValue: kSecCSSigningInformation),
|
SecCSFlags(rawValue: kSecCSSigningInformation),
|
||||||
&infoCF) == errSecSuccess,
|
&infoCF) == errSecSuccess,
|
||||||
let info = infoCF as? [String: Any]
|
let info = infoCF as? [String: Any]
|
||||||
else {
|
else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,12 +140,12 @@ enum PermissionManager {
|
|||||||
private static func ensureLocation(interactive: Bool) async -> Bool {
|
private static func ensureLocation(interactive: Bool) async -> Bool {
|
||||||
let status = CLLocationManager.authorizationStatus()
|
let status = CLLocationManager.authorizationStatus()
|
||||||
switch status {
|
switch status {
|
||||||
case .authorizedAlways, .authorized:
|
case .authorizedAlways, .authorizedWhenInUse:
|
||||||
return true
|
return true
|
||||||
case .notDetermined:
|
case .notDetermined:
|
||||||
guard interactive else { return false }
|
guard interactive else { return false }
|
||||||
let updated = await LocationPermissionRequester.shared.request(always: false)
|
let updated = await LocationPermissionRequester.shared.request(always: false)
|
||||||
return updated == .authorizedAlways || updated == .authorized
|
return updated == .authorizedAlways || updated == .authorizedWhenInUse
|
||||||
case .denied, .restricted:
|
case .denied, .restricted:
|
||||||
if interactive {
|
if interactive {
|
||||||
LocationPermissionHelper.openSettings()
|
LocationPermissionHelper.openSettings()
|
||||||
@@ -198,10 +198,9 @@ enum PermissionManager {
|
|||||||
|
|
||||||
case .camera:
|
case .camera:
|
||||||
results[cap] = AVCaptureDevice.authorizationStatus(for: .video) == .authorized
|
results[cap] = AVCaptureDevice.authorizationStatus(for: .video) == .authorized
|
||||||
|
|
||||||
case .location:
|
case .location:
|
||||||
let status = CLLocationManager.authorizationStatus()
|
let status = CLLocationManager.authorizationStatus()
|
||||||
results[cap] = status == .authorizedAlways || status == .authorized
|
results[cap] = status == .authorizedAlways || status == .authorizedWhenInUse
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return results
|
return results
|
||||||
@@ -269,7 +268,7 @@ enum LocationPermissionHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class LocationPermissionRequester: NSObject {
|
final class LocationPermissionRequester: NSObject, CLLocationManagerDelegate {
|
||||||
static let shared = LocationPermissionRequester()
|
static let shared = LocationPermissionRequester()
|
||||||
private let manager = CLLocationManager()
|
private let manager = CLLocationManager()
|
||||||
private var continuation: CheckedContinuation<CLAuthorizationStatus, Never>?
|
private var continuation: CheckedContinuation<CLAuthorizationStatus, Never>?
|
||||||
@@ -290,16 +289,17 @@ final class LocationPermissionRequester: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
// nonisolated for Swift 6 strict concurrency compatibility
|
||||||
guard let cont = self.continuation else { return }
|
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||||
self.continuation = nil
|
let status = manager.authorizationStatus
|
||||||
cont.resume(returning: 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 {
|
enum AppleScriptPermission {
|
||||||
private static let logger = Logger(subsystem: "com.clawdis", category: "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 timeoutMs = Int(Self.previewTimeoutSeconds * 1000)
|
||||||
let payload = try await AsyncTimeout.withTimeout(
|
let payload = try await AsyncTimeout.withTimeout(
|
||||||
seconds: Self.previewTimeoutSeconds,
|
seconds: Self.previewTimeoutSeconds,
|
||||||
onTimeout: { PreviewTimeoutError() })
|
onTimeout: { PreviewTimeoutError() }) {
|
||||||
{
|
try await GatewayConnection.shared.chatHistory(
|
||||||
try await GatewayConnection.shared.chatHistory(
|
sessionKey: self.sessionKey,
|
||||||
sessionKey: self.sessionKey,
|
limit: self.previewLimit,
|
||||||
limit: self.previewLimit,
|
timeoutMs: timeoutMs)
|
||||||
timeoutMs: timeoutMs)
|
}
|
||||||
}
|
|
||||||
let built = Self.previewItems(from: payload, maxItems: self.maxItems)
|
let built = Self.previewItems(from: payload, maxItems: self.maxItems)
|
||||||
await SessionPreviewCache.shared.store(items: built, for: self.sessionKey)
|
await SessionPreviewCache.shared.store(items: built, for: self.sessionKey)
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
@@ -199,9 +198,7 @@ struct SessionMenuPreviewView: View {
|
|||||||
self.status = .error("Preview unavailable")
|
self.status = .error("Preview unavailable")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Self.logger
|
Self.logger.warning("Session preview failed session=\(self.sessionKey, privacy: .public) error=\(String(describing: error), privacy: .public)")
|
||||||
.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,
|
requireCredentialsForServe: self.requireCredentialsForServe,
|
||||||
password: trimmedPassword,
|
password: trimmedPassword,
|
||||||
connectionMode: self.connectionMode,
|
connectionMode: self.connectionMode,
|
||||||
isPaused: self.isPaused)
|
isPaused: self.isPaused
|
||||||
|
)
|
||||||
|
|
||||||
if !success, let errorMessage {
|
if !success, let errorMessage {
|
||||||
self.statusMessage = errorMessage
|
self.statusMessage = errorMessage
|
||||||
@@ -306,8 +307,8 @@ struct TailscaleIntegrationSection: View {
|
|||||||
requireCredentialsForServe: Bool,
|
requireCredentialsForServe: Bool,
|
||||||
password: String,
|
password: String,
|
||||||
connectionMode: AppState.ConnectionMode,
|
connectionMode: AppState.ConnectionMode,
|
||||||
isPaused: Bool) async -> (Bool, String?)
|
isPaused: Bool
|
||||||
{
|
) async -> (Bool, String?) {
|
||||||
var root = await ConfigStore.load()
|
var root = await ConfigStore.load()
|
||||||
var gateway = root["gateway"] as? [String: Any] ?? [:]
|
var gateway = root["gateway"] as? [String: Any] ?? [:]
|
||||||
var tailscale = gateway["tailscale"] as? [String: Any] ?? [:]
|
var tailscale = gateway["tailscale"] as? [String: Any] ?? [:]
|
||||||
@@ -348,7 +349,7 @@ struct TailscaleIntegrationSection: View {
|
|||||||
do {
|
do {
|
||||||
try await ConfigStore.save(root)
|
try await ConfigStore.save(root)
|
||||||
return (true, nil)
|
return (true, nil)
|
||||||
} catch {
|
} catch let error {
|
||||||
return (false, error.localizedDescription)
|
return (false, error.localizedDescription)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,14 +35,14 @@ struct TalkOverlayView: View {
|
|||||||
.frame(width: 18, height: 18)
|
.frame(width: 18, height: 18)
|
||||||
.background(Color.black.opacity(0.4))
|
.background(Color.black.opacity(0.4))
|
||||||
.clipShape(Circle())
|
.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(
|
.frame(
|
||||||
width: TalkOverlayController.overlaySize,
|
width: TalkOverlayController.overlaySize,
|
||||||
@@ -124,7 +124,7 @@ private final class OrbInteractionNSView: NSView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override func mouseUp(with event: NSEvent) {
|
override func mouseUp(with event: NSEvent) {
|
||||||
if !self.didDrag, !self.suppressSingleClick {
|
if !self.didDrag && !self.suppressSingleClick {
|
||||||
self.onSingleClick?()
|
self.onSingleClick?()
|
||||||
}
|
}
|
||||||
self.mouseDownEvent = nil
|
self.mouseDownEvent = nil
|
||||||
@@ -148,8 +148,8 @@ private struct TalkOrbView: View {
|
|||||||
} else {
|
} else {
|
||||||
TimelineView(.animation) { context in
|
TimelineView(.animation) { context in
|
||||||
let t = context.date.timeIntervalSinceReferenceDate
|
let t = context.date.timeIntervalSinceReferenceDate
|
||||||
let listenScale = self.phase == .listening ? (1 + CGFloat(self.level) * 0.12) : 1
|
let listenScale = phase == .listening ? (1 + CGFloat(self.level) * 0.12) : 1
|
||||||
let pulse = self.phase == .speaking ? (1 + 0.06 * sin(t * 6)) : 1
|
let pulse = phase == .speaking ? (1 + 0.06 * sin(t * 6)) : 1
|
||||||
|
|
||||||
ZStack {
|
ZStack {
|
||||||
Circle()
|
Circle()
|
||||||
@@ -158,9 +158,9 @@ private struct TalkOrbView: View {
|
|||||||
.shadow(color: Color.black.opacity(0.22), radius: 10, x: 0, y: 5)
|
.shadow(color: Color.black.opacity(0.22), radius: 10, x: 0, y: 5)
|
||||||
.scaleEffect(pulse * listenScale)
|
.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)
|
TalkOrbitArcs(time: t)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,12 +186,11 @@ private struct TalkWaveRings: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
ForEach(0..<3, id: \.self) { idx in
|
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 progress = (time * speed + Double(idx) * 0.28).truncatingRemainder(dividingBy: 1)
|
||||||
let amplitude = self.phase == .speaking ? 0.95 : self.phase == .listening ? 0.5 + self
|
let amplitude = phase == .speaking ? 0.95 : phase == .listening ? 0.5 + level * 0.7 : 0.35
|
||||||
.level * 0.7 : 0.35
|
let scale = 0.75 + progress * amplitude + (phase == .listening ? level * 0.15 : 0)
|
||||||
let scale = 0.75 + progress * amplitude + (self.phase == .listening ? self.level * 0.15 : 0)
|
let alpha = phase == .speaking ? 0.72 : phase == .listening ? 0.58 + level * 0.28 : 0.4
|
||||||
let alpha = self.phase == .speaking ? 0.72 : self.phase == .listening ? 0.58 + self.level * 0.28 : 0.4
|
|
||||||
Circle()
|
Circle()
|
||||||
.stroke(self.accent.opacity(alpha - progress * 0.3), lineWidth: 1.6)
|
.stroke(self.accent.opacity(alpha - progress * 0.3), lineWidth: 1.6)
|
||||||
.scaleEffect(scale)
|
.scaleEffect(scale)
|
||||||
@@ -209,11 +208,11 @@ private struct TalkOrbitArcs: View {
|
|||||||
Circle()
|
Circle()
|
||||||
.trim(from: 0.08, to: 0.26)
|
.trim(from: 0.08, to: 0.26)
|
||||||
.stroke(Color.white.opacity(0.88), style: StrokeStyle(lineWidth: 1.6, lineCap: .round))
|
.stroke(Color.white.opacity(0.88), style: StrokeStyle(lineWidth: 1.6, lineCap: .round))
|
||||||
.rotationEffect(.degrees(self.time * 42))
|
.rotationEffect(.degrees(time * 42))
|
||||||
Circle()
|
Circle()
|
||||||
.trim(from: 0.62, to: 0.86)
|
.trim(from: 0.62, to: 0.86)
|
||||||
.stroke(Color.white.opacity(0.7), style: StrokeStyle(lineWidth: 1.4, lineCap: .round))
|
.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)
|
.scaleEffect(1.08)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ final class WorkActivityStore {
|
|||||||
meta: String?,
|
meta: String?,
|
||||||
args: [String: AnyCodable]?) -> 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)
|
let display = ToolDisplayRegistry.resolve(name: name ?? "tool", args: wrappedArgs, meta: meta)
|
||||||
if let detail = display.detailLine, !detail.isEmpty {
|
if let detail = display.detailLine, !detail.isEmpty {
|
||||||
return "\(display.label): \(detail)"
|
return "\(display.label): \(detail)"
|
||||||
@@ -223,22 +223,22 @@ final class WorkActivityStore {
|
|||||||
|
|
||||||
private static func wrapToolArgs(_ args: [String: AnyCodable]?) -> ClawdisKit.AnyCodable? {
|
private static func wrapToolArgs(_ args: [String: AnyCodable]?) -> ClawdisKit.AnyCodable? {
|
||||||
guard let args else { return nil }
|
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)
|
return ClawdisKit.AnyCodable(converted)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func unwrapJSONValue(_ value: Any) -> Any {
|
private static func unwrapJSONValue(_ value: Any) -> Any {
|
||||||
if let dict = value as? [String: AnyCodable] {
|
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] {
|
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] {
|
if let dict = value as? [String: Any] {
|
||||||
return dict.mapValues { self.unwrapJSONValue($0) }
|
return dict.mapValues { unwrapJSONValue($0) }
|
||||||
}
|
}
|
||||||
if let array = value as? [Any] {
|
if let array = value as? [Any] {
|
||||||
return array.map { self.unwrapJSONValue($0) }
|
return array.map { unwrapJSONValue($0) }
|
||||||
}
|
}
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ public enum ErrorCode: String, Codable {
|
|||||||
case unavailable = "UNAVAILABLE"
|
case unavailable = "UNAVAILABLE"
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct ConnectParams: Codable, Sendable {
|
public struct ConnectParams: Codable {
|
||||||
public let minprotocol: Int
|
public let minprotocol: Int
|
||||||
public let maxprotocol: Int
|
public let maxprotocol: Int
|
||||||
public let client: [String: AnyCodable]
|
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 type: String
|
||||||
public let _protocol: Int
|
public let _protocol: Int
|
||||||
public let server: [String: AnyCodable]
|
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 type: String
|
||||||
public let id: String
|
public let id: String
|
||||||
public let method: 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 type: String
|
||||||
public let id: String
|
public let id: String
|
||||||
public let ok: Bool
|
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 type: String
|
||||||
public let event: String
|
public let event: String
|
||||||
public let payload: AnyCodable?
|
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 host: String?
|
||||||
public let ip: String?
|
public let ip: String?
|
||||||
public let version: 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 presence: Int
|
||||||
public let health: 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 presence: [PresenceEntry]
|
||||||
public let health: AnyCodable
|
public let health: AnyCodable
|
||||||
public let stateversion: StateVersion
|
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 code: String
|
||||||
public let message: String
|
public let message: String
|
||||||
public let details: AnyCodable?
|
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 runid: String
|
||||||
public let seq: Int
|
public let seq: Int
|
||||||
public let stream: String
|
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 to: String
|
||||||
public let message: String
|
public let message: String
|
||||||
public let mediaurl: 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 message: String
|
||||||
public let to: String?
|
public let to: String?
|
||||||
public let sessionid: 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 runid: String
|
||||||
public let afterms: Int?
|
public let afterms: Int?
|
||||||
public let timeoutms: 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 mode: AnyCodable
|
||||||
public let text: String
|
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 nodeid: String
|
||||||
public let displayname: String?
|
public let displayname: String?
|
||||||
public let platform: 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 let requestid: String
|
||||||
|
|
||||||
public init(
|
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 let requestid: String
|
||||||
|
|
||||||
public init(
|
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 nodeid: String
|
||||||
public let token: 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 nodeid: String
|
||||||
public let displayname: 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 let nodeid: String
|
||||||
|
|
||||||
public init(
|
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 nodeid: String
|
||||||
public let command: String
|
public let command: String
|
||||||
public let params: AnyCodable?
|
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 limit: Int?
|
||||||
public let activeminutes: Int?
|
public let activeminutes: Int?
|
||||||
public let includeglobal: Bool?
|
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 key: String
|
||||||
public let thinkinglevel: AnyCodable?
|
public let thinkinglevel: AnyCodable?
|
||||||
public let verboselevel: AnyCodable?
|
public let verboselevel: AnyCodable?
|
||||||
|
public let elevatedlevel: AnyCodable?
|
||||||
public let model: AnyCodable?
|
public let model: AnyCodable?
|
||||||
public let sendpolicy: AnyCodable?
|
public let sendpolicy: AnyCodable?
|
||||||
public let groupactivation: AnyCodable?
|
public let groupactivation: AnyCodable?
|
||||||
@@ -654,6 +655,7 @@ public struct SessionsPatchParams: Codable, Sendable {
|
|||||||
key: String,
|
key: String,
|
||||||
thinkinglevel: AnyCodable?,
|
thinkinglevel: AnyCodable?,
|
||||||
verboselevel: AnyCodable?,
|
verboselevel: AnyCodable?,
|
||||||
|
elevatedlevel: AnyCodable?,
|
||||||
model: AnyCodable?,
|
model: AnyCodable?,
|
||||||
sendpolicy: AnyCodable?,
|
sendpolicy: AnyCodable?,
|
||||||
groupactivation: AnyCodable?
|
groupactivation: AnyCodable?
|
||||||
@@ -661,6 +663,7 @@ public struct SessionsPatchParams: Codable, Sendable {
|
|||||||
self.key = key
|
self.key = key
|
||||||
self.thinkinglevel = thinkinglevel
|
self.thinkinglevel = thinkinglevel
|
||||||
self.verboselevel = verboselevel
|
self.verboselevel = verboselevel
|
||||||
|
self.elevatedlevel = elevatedlevel
|
||||||
self.model = model
|
self.model = model
|
||||||
self.sendpolicy = sendpolicy
|
self.sendpolicy = sendpolicy
|
||||||
self.groupactivation = groupactivation
|
self.groupactivation = groupactivation
|
||||||
@@ -669,13 +672,14 @@ public struct SessionsPatchParams: Codable, Sendable {
|
|||||||
case key
|
case key
|
||||||
case thinkinglevel = "thinkingLevel"
|
case thinkinglevel = "thinkingLevel"
|
||||||
case verboselevel = "verboseLevel"
|
case verboselevel = "verboseLevel"
|
||||||
|
case elevatedlevel = "elevatedLevel"
|
||||||
case model
|
case model
|
||||||
case sendpolicy = "sendPolicy"
|
case sendpolicy = "sendPolicy"
|
||||||
case groupactivation = "groupActivation"
|
case groupactivation = "groupActivation"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct SessionsResetParams: Codable, Sendable {
|
public struct SessionsResetParams: Codable {
|
||||||
public let key: String
|
public let key: String
|
||||||
|
|
||||||
public init(
|
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 key: String
|
||||||
public let deletetranscript: Bool?
|
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 key: String
|
||||||
public let maxlines: Int?
|
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 let raw: String
|
||||||
|
|
||||||
public init(
|
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 schema: AnyCodable
|
||||||
public let uihints: [String: AnyCodable]
|
public let uihints: [String: AnyCodable]
|
||||||
public let version: String
|
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 mode: AnyCodable?
|
||||||
public let workspace: String?
|
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 sessionid: String
|
||||||
public let answer: [String: AnyCodable]?
|
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 let sessionid: String
|
||||||
|
|
||||||
public init(
|
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 let sessionid: String
|
||||||
|
|
||||||
public init(
|
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 id: String
|
||||||
public let type: AnyCodable
|
public let type: AnyCodable
|
||||||
public let title: String?
|
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 done: Bool
|
||||||
public let step: [String: AnyCodable]?
|
public let step: [String: AnyCodable]?
|
||||||
public let status: 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 sessionid: String
|
||||||
public let done: Bool
|
public let done: Bool
|
||||||
public let step: [String: AnyCodable]?
|
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 status: AnyCodable
|
||||||
public let error: String?
|
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 enabled: Bool
|
||||||
public let phase: String?
|
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 probe: Bool?
|
||||||
public let timeoutms: Int?
|
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 force: Bool?
|
||||||
public let timeoutms: Int?
|
public let timeoutms: Int?
|
||||||
public let verbose: Bool?
|
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 let timeoutms: Int?
|
||||||
|
|
||||||
public init(
|
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 id: String
|
||||||
public let name: String
|
public let name: String
|
||||||
public let provider: 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 let models: [ModelChoice]
|
||||||
|
|
||||||
public init(
|
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 name: String
|
||||||
public let installid: String
|
public let installid: String
|
||||||
public let timeoutms: Int?
|
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 skillkey: String
|
||||||
public let enabled: Bool?
|
public let enabled: Bool?
|
||||||
public let apikey: String?
|
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 id: String
|
||||||
public let name: String
|
public let name: String
|
||||||
public let description: 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 let includedisabled: Bool?
|
||||||
|
|
||||||
public init(
|
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 name: String
|
||||||
public let description: String?
|
public let description: String?
|
||||||
public let enabled: Bool?
|
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 id: String
|
||||||
public let patch: [String: AnyCodable]
|
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 let id: String
|
||||||
|
|
||||||
public init(
|
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 id: String
|
||||||
public let mode: AnyCodable?
|
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 id: String
|
||||||
public let limit: Int?
|
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 ts: Int
|
||||||
public let jobid: String
|
public let jobid: String
|
||||||
public let action: 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 sessionkey: String
|
||||||
public let limit: Int?
|
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 sessionkey: String
|
||||||
public let message: String
|
public let message: String
|
||||||
public let thinking: 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 sessionkey: String
|
||||||
public let runid: 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 runid: String
|
||||||
public let sessionkey: String
|
public let sessionkey: String
|
||||||
public let seq: Int
|
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 let ts: Int
|
||||||
|
|
||||||
public init(
|
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 reason: String
|
||||||
public let restartexpectedms: Int?
|
public let restartexpectedms: Int?
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ Key parameters:
|
|||||||
- `yieldMs` (default 10000): auto‑background after this delay
|
- `yieldMs` (default 10000): auto‑background after this delay
|
||||||
- `background` (bool): background immediately
|
- `background` (bool): background immediately
|
||||||
- `timeout` (seconds, default 1800): kill the process after this timeout
|
- `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.
|
- Need a real TTY? Use the tmux skill.
|
||||||
- `workdir`, `env`
|
- `workdir`, `env`
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ Run shell commands in the workspace. Supports foreground + background execution
|
|||||||
- `yieldMs` (default 10000): auto-background after delay
|
- `yieldMs` (default 10000): auto-background after delay
|
||||||
- `background` (bool): background immediately
|
- `background` (bool): background immediately
|
||||||
- `timeout` (seconds, default 1800): kill on expiry
|
- `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.
|
- Need a real TTY? Use the tmux skill.
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|||||||
@@ -153,6 +153,59 @@ Hooks (arming):
|
|||||||
Clawdis should treat "open/closed" as a health check (fast path), not by scanning
|
Clawdis should treat "open/closed" as a health check (fast path), not by scanning
|
||||||
global Chrome processes (avoid false positives).
|
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)
|
## Interaction with the agent (clawd)
|
||||||
|
|
||||||
The agent should use browser tools only when:
|
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)
|
## 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:
|
Basics:
|
||||||
- `clawdis browser status`
|
- `clawdis browser status`
|
||||||
- `clawdis browser start`
|
- `clawdis browser start`
|
||||||
|
|||||||
+39
-2
@@ -388,6 +388,7 @@ Controls the embedded agent runtime (model/thinking/verbose/timeouts).
|
|||||||
},
|
},
|
||||||
thinkingDefault: "low",
|
thinkingDefault: "low",
|
||||||
verboseDefault: "off",
|
verboseDefault: "off",
|
||||||
|
elevatedDefault: "on",
|
||||||
timeoutSeconds: 600,
|
timeoutSeconds: 600,
|
||||||
mediaMaxMb: 5,
|
mediaMaxMb: 5,
|
||||||
heartbeat: {
|
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)
|
- `timeoutSec`: auto-kill after this runtime (seconds, default 1800)
|
||||||
- `cleanupMs`: how long to keep finished sessions in memory (ms, default 1800000)
|
- `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
|
`agent.maxConcurrent` sets the maximum number of embedded agent runs that can
|
||||||
execute in parallel across sessions. Each session is still serialized (one run
|
execute in parallel across sessions. Each session is still serialized (one run
|
||||||
per session key at a time). Default: 1.
|
per session key at a time). Default: 1.
|
||||||
@@ -692,11 +718,16 @@ Example:
|
|||||||
### `browser` (clawd-managed Chrome)
|
### `browser` (clawd-managed Chrome)
|
||||||
|
|
||||||
Clawdis can start a **dedicated, isolated** Chrome/Chromium instance for clawd and expose a small loopback control server.
|
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:
|
Defaults:
|
||||||
- enabled: `true`
|
- enabled: `true`
|
||||||
- control URL: `http://127.0.0.1:18791` (CDP uses `18792`)
|
- 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)
|
- profile color: `#FF4500` (lobster-orange)
|
||||||
- Note: the control server is started by the running gateway (Clawdis.app menubar, or `clawdis gateway`).
|
- Note: the control server is started by the running gateway (Clawdis.app menubar, or `clawdis gateway`).
|
||||||
|
|
||||||
@@ -705,7 +736,13 @@ Defaults:
|
|||||||
browser: {
|
browser: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
controlUrl: "http://127.0.0.1:18791",
|
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",
|
color: "#FF4500",
|
||||||
// Advanced:
|
// Advanced:
|
||||||
// headless: false,
|
// 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 |
|
| `/new` or `/reset` | Reset the session |
|
||||||
| `/think <level>` | Set thinking level (off\|minimal\|low\|medium\|high) |
|
| `/think <level>` | Set thinking level (off\|minimal\|low\|medium\|high) |
|
||||||
| `/verbose on\|off` | Toggle verbose mode |
|
| `/verbose on\|off` | Toggle verbose mode |
|
||||||
|
| `/elevated on\|off` | Toggle elevated bash mode (approved senders only) |
|
||||||
| `/activation mention\|always` | Group activation (owner-only) |
|
| `/activation mention\|always` | Group activation (owner-only) |
|
||||||
| `/model <name>` | Switch AI model (see below) |
|
| `/model <name>` | Switch AI model (see below) |
|
||||||
| `/queue instant\|batch\|serial` | Message queuing mode |
|
| `/queue instant\|batch\|serial` | Message queuing mode |
|
||||||
|
|||||||
@@ -110,6 +110,20 @@ Notes:
|
|||||||
- “Always” requires system permission; background fetch is best-effort.
|
- “Always” requires system permission; background fetch is best-effort.
|
||||||
- The response includes lat/lon, accuracy (meters), and timestamp.
|
- 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)
|
## System commands (mac node)
|
||||||
|
|
||||||
The macOS node exposes `system.run` and `system.notify`.
|
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:
|
read_when:
|
||||||
- Adjusting thinking level parsing or defaults
|
- Adjusting thinking or verbose directive parsing or defaults
|
||||||
---
|
---
|
||||||
# Thinking Levels (/think directives)
|
# Thinking Levels (/think directives)
|
||||||
|
|
||||||
@@ -34,6 +34,9 @@ read_when:
|
|||||||
- Inline directive affects only that message; session/global defaults apply otherwise.
|
- 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.
|
- 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
|
## Heartbeats
|
||||||
- Heartbeat probe body is `HEARTBEAT`. Inline directives in a heartbeat message apply as usual (but avoid changing session defaults from 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)
|
- `yieldMs` (auto-background after timeout, default 10000)
|
||||||
- `background` (immediate background)
|
- `background` (immediate background)
|
||||||
- `timeout` (seconds; kills the process if exceeded, default 1800)
|
- `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.
|
- Need a real TTY? Use the tmux skill.
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
@@ -47,9 +48,23 @@ Core actions:
|
|||||||
- `act` (UI actions: click/type/press/hover/drag/select/fill/resize/wait/evaluate)
|
- `act` (UI actions: click/type/press/hover/drag/select/fill/resize/wait/evaluate)
|
||||||
- `navigate`, `console`, `pdf`, `upload`, `dialog`
|
- `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:
|
Notes:
|
||||||
- Requires `browser.enabled=true` in `~/.clawdis/clawdis.json`.
|
- Requires `browser.enabled=true` in `~/.clawdis/clawdis.json`.
|
||||||
- Uses `browser.controlUrl` unless `controlUrl` is passed explicitly.
|
- 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.
|
- `snapshot` defaults to `ai`; use `aria` for the accessibility tree.
|
||||||
- `act` requires `ref` from `snapshot --format ai`; use `evaluate` for rare CSS selector needs.
|
- `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).
|
- 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`)
|
- `/model <provider/model>` (or `/models`)
|
||||||
- `/think <off|minimal|low|medium|high>`
|
- `/think <off|minimal|low|medium|high>`
|
||||||
- `/verbose <on|off>`
|
- `/verbose <on|off>`
|
||||||
|
- `/elevated <on|off>`
|
||||||
- `/activation <mention|always>`
|
- `/activation <mention|always>`
|
||||||
- `/deliver <on|off>`
|
- `/deliver <on|off>`
|
||||||
- `/new` or `/reset`
|
- `/new` or `/reset`
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ function emitStruct(name: string, schema: JsonSchema): string {
|
|||||||
const props = schema.properties ?? {};
|
const props = schema.properties ?? {};
|
||||||
const required = new Set(schema.required ?? []);
|
const required = new Set(schema.required ?? []);
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
lines.push(`public struct ${name}: Codable, Sendable {`);
|
lines.push(`public struct ${name}: Codable {`);
|
||||||
if (Object.keys(props).length === 0) {
|
if (Object.keys(props).length === 0) {
|
||||||
lines.push("}\n");
|
lines.push("}\n");
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
export DISPLAY=:1
|
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}"
|
CDP_PORT="${CLAWDIS_BROWSER_CDP_PORT:-9222}"
|
||||||
VNC_PORT="${CLAWDIS_BROWSER_VNC_PORT:-5900}"
|
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}"
|
ENABLE_NOVNC="${CLAWDIS_BROWSER_ENABLE_NOVNC:-1}"
|
||||||
HEADLESS="${CLAWDIS_BROWSER_HEADLESS:-0}"
|
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 &
|
Xvfb :1 -screen 0 1280x800x24 -ac -nolisten tcp &
|
||||||
|
|
||||||
@@ -25,12 +28,14 @@ fi
|
|||||||
CHROME_ARGS+=(
|
CHROME_ARGS+=(
|
||||||
"--remote-debugging-address=0.0.0.0"
|
"--remote-debugging-address=0.0.0.0"
|
||||||
"--remote-debugging-port=${CDP_PORT}"
|
"--remote-debugging-port=${CDP_PORT}"
|
||||||
"--user-data-dir=/workspace/.chrome"
|
"--user-data-dir=${HOME}/.chrome"
|
||||||
"--no-first-run"
|
"--no-first-run"
|
||||||
"--no-default-browser-check"
|
"--no-default-browser-check"
|
||||||
"--disable-dev-shm-usage"
|
"--disable-dev-shm-usage"
|
||||||
"--disable-background-networking"
|
"--disable-background-networking"
|
||||||
"--disable-features=TranslateUI"
|
"--disable-features=TranslateUI"
|
||||||
|
"--disable-breakpad"
|
||||||
|
"--disable-crash-reporter"
|
||||||
"--metrics-recording-only"
|
"--metrics-recording-only"
|
||||||
"--no-sandbox"
|
"--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
|
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
|
# 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.
|
- For stickers/polls/sendMessage: a `to` target (`channel:<id>` or `user:<id>`). Optional `content` text.
|
||||||
- Polls also need a `question` plus 2–10 `answers`.
|
- Polls also need a `question` plus 2–10 `answers`.
|
||||||
- For media: `mediaUrl` with `file:///path` for local files or `https://...` for remote.
|
- 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.
|
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.
|
- Up to 3 sticker IDs per message.
|
||||||
- `to` can be `user:<id>` for DMs.
|
- `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
|
### Create a poll
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -88,6 +121,7 @@ Message context lines include `discord message id` and `channel` fields you can
|
|||||||
- React with ✅/⚠️ to mark status updates.
|
- React with ✅/⚠️ to mark status updates.
|
||||||
- Post a quick poll for release decisions or meeting times.
|
- Post a quick poll for release decisions or meeting times.
|
||||||
- Send celebratory stickers after successful deploys.
|
- Send celebratory stickers after successful deploys.
|
||||||
|
- Upload new emojis/stickers for release moments.
|
||||||
- Run weekly “priority check” polls in team channels.
|
- Run weekly “priority check” polls in team channels.
|
||||||
- DM stickers as acknowledgements when a user’s request is completed.
|
- 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:
|
Use `discord.actions.*` to disable action groups:
|
||||||
- `reactions` (react + reactions list + emojiList)
|
- `reactions` (react + reactions list + emojiList)
|
||||||
- `stickers`, `polls`, `permissions`, `messages`, `threads`, `pins`, `search`
|
- `stickers`, `polls`, `permissions`, `messages`, `threads`, `pins`, `search`
|
||||||
|
- `emojiUploads`, `stickerUploads`
|
||||||
- `memberInfo`, `roleInfo`, `channelInfo`, `voiceStatus`, `events`
|
- `memberInfo`, `roleInfo`, `channelInfo`, `voiceStatus`, `events`
|
||||||
- `roles` (role add/remove, default `false`)
|
- `roles` (role add/remove, default `false`)
|
||||||
- `moderation` (timeout/kick/ban, 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
|
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
|
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
|
# wacli
|
||||||
|
|
||||||
Use `wacli` for WhatsApp sync/search/send via WhatsApp Web protocol.
|
Use `wacli` only when the user explicitly asks you to message someone else on WhatsApp or when they ask to sync/search WhatsApp history.
|
||||||
Normal operation: the user chats with you via Telegram or WhatsApp and Clawdis handles it automatically.
|
Do NOT use `wacli` for normal user chats; Clawdis routes WhatsApp conversations automatically.
|
||||||
Use `wacli` only when you need to talk to other people on WhatsApp (or to debug sync/search).
|
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
|
Auth + sync
|
||||||
- `wacli auth` (QR login + initial sync)
|
- `wacli auth` (QR login + initial sync)
|
||||||
- `wacli sync --follow` (continuous sync)
|
- `wacli sync --follow` (continuous sync)
|
||||||
- `wacli doctor`
|
- `wacli doctor`
|
||||||
|
|
||||||
Search + history
|
Find chats + messages
|
||||||
- `wacli messages search "query"`
|
- `wacli chats list --limit 20 --query "name or number"`
|
||||||
- `wacli history backfill --chat <jid> --requests 5 --count 50`
|
- `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
|
Send
|
||||||
- Text: `wacli send text --to 1234567890 --message "hello"`
|
- Text: `wacli send text --to "+14155551212" --message "Hello! Are you free at 3pm?"`
|
||||||
- File: `wacli send file --to 1234567890 --file /path/pic.jpg --caption "hi"`
|
- 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
|
Notes
|
||||||
- Store dir: `~/.wacli` (override with `--store`).
|
- Store dir: `~/.wacli` (override with `--store`).
|
||||||
|
- Use `--json` for machine-readable output when parsing.
|
||||||
- Backfill requires your phone online; results are best-effort.
|
- Backfill requires your phone online; results are best-effort.
|
||||||
- WhatsApp CLI is not needed for routine user chats; it’s for messaging other people.
|
- 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");
|
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 () => {
|
it("logs line-based slices and defaults to last lines", async () => {
|
||||||
const result = await bashTool.execute("call1", {
|
const result = await bashTool.execute("call1", {
|
||||||
command:
|
command:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import path from "node:path";
|
|||||||
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
|
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||||
import { Type } from "@sinclair/typebox";
|
import { Type } from "@sinclair/typebox";
|
||||||
|
|
||||||
|
import { logInfo } from "../logger.js";
|
||||||
import {
|
import {
|
||||||
addSession,
|
addSession,
|
||||||
appendOutput,
|
appendOutput,
|
||||||
@@ -53,6 +54,7 @@ export type BashToolDefaults = {
|
|||||||
backgroundMs?: number;
|
backgroundMs?: number;
|
||||||
timeoutSec?: number;
|
timeoutSec?: number;
|
||||||
sandbox?: BashSandboxConfig;
|
sandbox?: BashSandboxConfig;
|
||||||
|
elevated?: BashElevatedDefaults;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ProcessToolDefaults = {
|
export type ProcessToolDefaults = {
|
||||||
@@ -66,6 +68,12 @@ export type BashSandboxConfig = {
|
|||||||
env?: Record<string, string>;
|
env?: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type BashElevatedDefaults = {
|
||||||
|
enabled: boolean;
|
||||||
|
allowed: boolean;
|
||||||
|
defaultLevel: "on" | "off";
|
||||||
|
};
|
||||||
|
|
||||||
const bashSchema = Type.Object({
|
const bashSchema = Type.Object({
|
||||||
command: Type.String({ description: "Bash command to execute" }),
|
command: Type.String({ description: "Bash command to execute" }),
|
||||||
workdir: Type.Optional(
|
workdir: Type.Optional(
|
||||||
@@ -85,6 +93,11 @@ const bashSchema = Type.Object({
|
|||||||
description: "Timeout in seconds (optional, kills process on expiry)",
|
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 =
|
export type BashToolDetails =
|
||||||
@@ -131,6 +144,7 @@ export function createBashTool(
|
|||||||
yieldMs?: number;
|
yieldMs?: number;
|
||||||
background?: boolean;
|
background?: boolean;
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
|
elevated?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!params.command) {
|
if (!params.command) {
|
||||||
@@ -149,7 +163,24 @@ export function createBashTool(
|
|||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
const sessionId = randomUUID();
|
const sessionId = randomUUID();
|
||||||
const warnings: string[] = [];
|
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();
|
const rawWorkdir = params.workdir?.trim() || process.cwd();
|
||||||
let workdir = rawWorkdir;
|
let workdir = rawWorkdir;
|
||||||
let containerWorkdir = sandbox?.containerWorkdir;
|
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";
|
} from "../process/command-queue.js";
|
||||||
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
|
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
|
||||||
import { resolveClawdisAgentDir } from "./agent-paths.js";
|
import { resolveClawdisAgentDir } from "./agent-paths.js";
|
||||||
|
import type { BashElevatedDefaults } from "./bash-tools.js";
|
||||||
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
|
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
|
||||||
import { ensureClawdisModelsJson } from "./models-config.js";
|
import { ensureClawdisModelsJson } from "./models-config.js";
|
||||||
import {
|
import {
|
||||||
@@ -390,6 +391,7 @@ export async function runEmbeddedPiAgent(params: {
|
|||||||
model?: string;
|
model?: string;
|
||||||
thinkLevel?: ThinkLevel;
|
thinkLevel?: ThinkLevel;
|
||||||
verboseLevel?: VerboseLevel;
|
verboseLevel?: VerboseLevel;
|
||||||
|
bashElevated?: BashElevatedDefaults;
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
runId: string;
|
runId: string;
|
||||||
abortSignal?: AbortSignal;
|
abortSignal?: AbortSignal;
|
||||||
@@ -495,7 +497,10 @@ export async function runEmbeddedPiAgent(params: {
|
|||||||
const contextFiles = buildBootstrapContextFiles(bootstrapFiles);
|
const contextFiles = buildBootstrapContextFiles(bootstrapFiles);
|
||||||
const promptSkills = resolvePromptSkills(skillsSnapshot, skillEntries);
|
const promptSkills = resolvePromptSkills(skillsSnapshot, skillEntries);
|
||||||
const tools = createClawdisCodingTools({
|
const tools = createClawdisCodingTools({
|
||||||
bash: params.config?.agent?.bash,
|
bash: {
|
||||||
|
...params.config?.agent?.bash,
|
||||||
|
elevated: params.bashElevated,
|
||||||
|
},
|
||||||
sandbox,
|
sandbox,
|
||||||
surface: params.surface,
|
surface: params.surface,
|
||||||
sessionKey: params.sessionKey ?? params.sessionId,
|
sessionKey: params.sessionKey ?? params.sessionId,
|
||||||
|
|||||||
+13
-5
@@ -9,7 +9,10 @@ import {
|
|||||||
startBrowserBridgeServer,
|
startBrowserBridgeServer,
|
||||||
stopBrowserBridgeServer,
|
stopBrowserBridgeServer,
|
||||||
} from "../browser/bridge-server.js";
|
} 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 { DEFAULT_CLAWD_BROWSER_COLOR } from "../browser/constants.js";
|
||||||
import type { ClawdisConfig } from "../config/config.js";
|
import type { ClawdisConfig } from "../config/config.js";
|
||||||
import { STATE_DIR_CLAWDIS } 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 controlHost = "127.0.0.1";
|
||||||
const controlUrl = `http://${controlHost}:${params.controlPort}`;
|
const controlUrl = `http://${controlHost}:${params.controlPort}`;
|
||||||
const cdpHost = "127.0.0.1";
|
const cdpHost = "127.0.0.1";
|
||||||
const cdpUrl = `http://${cdpHost}:${params.cdpPort}`;
|
|
||||||
return {
|
return {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
controlUrl,
|
controlUrl,
|
||||||
controlHost,
|
controlHost,
|
||||||
controlPort: params.controlPort,
|
controlPort: params.controlPort,
|
||||||
cdpUrl,
|
cdpProtocol: "http",
|
||||||
cdpHost,
|
cdpHost,
|
||||||
cdpPort: params.cdpPort,
|
|
||||||
cdpIsLoopback: true,
|
cdpIsLoopback: true,
|
||||||
color: DEFAULT_CLAWD_BROWSER_COLOR,
|
color: DEFAULT_CLAWD_BROWSER_COLOR,
|
||||||
executablePath: undefined,
|
executablePath: undefined,
|
||||||
headless: params.headless,
|
headless: params.headless,
|
||||||
noSandbox: false,
|
noSandbox: false,
|
||||||
attachOnly: true,
|
attachOnly: true,
|
||||||
|
defaultProfile: "clawd",
|
||||||
|
profiles: {
|
||||||
|
clawd: { cdpPort: params.cdpPort, color: DEFAULT_CLAWD_BROWSER_COLOR },
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -600,10 +605,13 @@ async function ensureSandboxBrowser(params: {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
const existing = BROWSER_BRIDGES.get(params.sessionKey);
|
const existing = BROWSER_BRIDGES.get(params.sessionKey);
|
||||||
|
const existingProfile = existing
|
||||||
|
? resolveProfile(existing.bridge.state.resolved, "clawd")
|
||||||
|
: null;
|
||||||
const shouldReuse =
|
const shouldReuse =
|
||||||
existing &&
|
existing &&
|
||||||
existing.containerName === containerName &&
|
existing.containerName === containerName &&
|
||||||
existing.bridge.state.resolved.cdpPort === mappedCdp;
|
existingProfile?.cdpPort === mappedCdp;
|
||||||
if (existing && !shouldReuse) {
|
if (existing && !shouldReuse) {
|
||||||
await stopBrowserBridgeServer(existing.bridge.server).catch(
|
await stopBrowserBridgeServer(existing.bridge.server).catch(
|
||||||
() => undefined,
|
() => undefined,
|
||||||
|
|||||||
@@ -196,6 +196,8 @@
|
|||||||
"memberInfo": { "label": "member", "detailKeys": ["guildId", "userId"] },
|
"memberInfo": { "label": "member", "detailKeys": ["guildId", "userId"] },
|
||||||
"roleInfo": { "label": "roles", "detailKeys": ["guildId"] },
|
"roleInfo": { "label": "roles", "detailKeys": ["guildId"] },
|
||||||
"emojiList": { "label": "emoji list", "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"] },
|
"roleAdd": { "label": "role add", "detailKeys": ["guildId", "userId", "roleId"] },
|
||||||
"roleRemove": { "label": "role remove", "detailKeys": ["guildId", "userId", "roleId"] },
|
"roleRemove": { "label": "role remove", "detailKeys": ["guildId", "userId", "roleId"] },
|
||||||
"channelInfo": { "label": "channel", "detailKeys": ["channelId"] },
|
"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";
|
} from "../config/sessions.js";
|
||||||
import { drainSystemEvents } from "../infra/system-events.js";
|
import { drainSystemEvents } from "../infra/system-events.js";
|
||||||
import {
|
import {
|
||||||
|
extractElevatedDirective,
|
||||||
extractQueueDirective,
|
extractQueueDirective,
|
||||||
extractReplyToTag,
|
extractReplyToTag,
|
||||||
extractThinkDirective,
|
extractThinkDirective,
|
||||||
@@ -85,6 +86,12 @@ describe("directive parsing", () => {
|
|||||||
expect(res.verboseLevel).toBe("on");
|
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", () => {
|
it("matches think at start of line", () => {
|
||||||
const res = extractThinkDirective("/think:high run slow");
|
const res = extractThinkDirective("/think:high run slow");
|
||||||
expect(res.hasDirective).toBe(true);
|
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 () => {
|
it("acks queue directive and persists override", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||||
@@ -744,4 +783,49 @@ describe("directive parsing", () => {
|
|||||||
expect(call?.thinkLevel).toBe("low");
|
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 () => {
|
it("returns a context overflow fallback when the embedded agent throws", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
vi.mocked(runEmbeddedPiAgent).mockRejectedValue(
|
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