Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f975ebf91b | ||
|
|
f2db894685 | ||
|
|
4f03283126 | ||
|
|
ed7dec0975 | ||
|
|
fbb3da506f | ||
|
|
dfa6c5c2b3 | ||
|
|
028eed5fe8 | ||
|
|
2b16a87f04 | ||
|
|
731049936d | ||
|
|
5a5b058ba0 | ||
|
|
72f28be648 | ||
|
|
ff645524d8 | ||
|
|
c534390bc0 | ||
|
|
c5003e5441 | ||
|
|
0b3ebb0c63 | ||
|
|
23981496f9 | ||
|
|
f2e425dc2b | ||
|
|
e48d68bbc7 | ||
|
|
842fc8d08b | ||
|
|
54ec14262b | ||
|
|
d0c70178e0 | ||
|
|
3bc9c330eb | ||
|
|
b7fcc8584f | ||
|
|
fa9aafce83 | ||
|
|
0d5dec4c66 | ||
|
|
b2d5889f6e | ||
|
|
2ee71e4154 |
No files matched your search
@@ -116,6 +116,14 @@
|
|||||||
- launchd PATH is minimal; ensure the app’s launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`clawdbot` binaries resolve when invoked via `clawdbot-mac`.
|
- launchd PATH is minimal; ensure the app’s launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`clawdbot` binaries resolve when invoked via `clawdbot-mac`.
|
||||||
- For manual `clawdbot message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
|
- For manual `clawdbot message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
|
||||||
|
|
||||||
|
## NPM + 1Password (publish/verify)
|
||||||
|
- Use the 1password skill; all `op` commands must run inside a fresh tmux session.
|
||||||
|
- Sign in: `eval "$(op signin --account my.1password.com)"` (app unlocked + integration on).
|
||||||
|
- OTP: `op read 'op://Private/Npmjs/one-time password?attribute=otp'`.
|
||||||
|
- Publish: `npm publish --access public --otp="<otp>"` (run from the package dir).
|
||||||
|
- Verify without local npmrc side effects: `npm view <pkg> version --userconfig "$(mktemp)"`.
|
||||||
|
- Kill the tmux session after publish.
|
||||||
|
|
||||||
## Exclamation Mark Escaping Workaround
|
## Exclamation Mark Escaping Workaround
|
||||||
The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdbot message send` with messages containing exclamation marks, use heredoc syntax:
|
The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdbot message send` with messages containing exclamation marks, use heredoc syntax:
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -26,7 +26,7 @@
|
|||||||
- Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.
|
- Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.
|
||||||
- Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
|
- Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
|
||||||
- Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639.
|
- Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639.
|
||||||
- Fix: parse systemd ExecStart arguments when whitespace is present. (#995) — thanks @roshanasingh4.
|
- Fix: macOS gateway stays attached across restarts (no port flapping) and status probes pick the right auth. (#982) — thanks @wes-davis.
|
||||||
- CLI: add `--json` output for `clawdbot daemon` lifecycle/install commands.
|
- CLI: add `--json` output for `clawdbot daemon` lifecycle/install commands.
|
||||||
- Memory: make `node-llama-cpp` an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.
|
- Memory: make `node-llama-cpp` an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.
|
||||||
- Browser: add `snapshot refs=aria` (Playwright aria-ref ids) for self-resolving refs across `snapshot` → `act`.
|
- Browser: add `snapshot refs=aria` (Playwright aria-ref ids) for self-resolving refs across `snapshot` → `act`.
|
||||||
@@ -37,6 +37,7 @@
|
|||||||
- Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi.
|
- Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi.
|
||||||
- Telegram: skip `message_thread_id=1` for General topic sends while keeping typing indicators. (#848) — thanks @azade-c.
|
- Telegram: skip `message_thread_id=1` for General topic sends while keeping typing indicators. (#848) — thanks @azade-c.
|
||||||
- Discord: allow allowlisted guilds without channel lists to receive messages when `groupPolicy="allowlist"`. — thanks @thewilloftheshadow.
|
- Discord: allow allowlisted guilds without channel lists to receive messages when `groupPolicy="allowlist"`. — thanks @thewilloftheshadow.
|
||||||
|
- Discord: allow emoji/sticker uploads + channel actions in config defaults. (#870) — thanks @JDIVE.
|
||||||
- Fix: sanitize user-facing error text + strip `<final>` tags across reply pipelines. (#975) — thanks @ThomsenDrake.
|
- Fix: sanitize user-facing error text + strip `<final>` tags across reply pipelines. (#975) — thanks @ThomsenDrake.
|
||||||
- Fix: normalize pairing CLI aliases, allow extension channels, and harden Zalo webhook payload parsing. (#991) — thanks @longmaba.
|
- Fix: normalize pairing CLI aliases, allow extension channels, and harden Zalo webhook payload parsing. (#991) — thanks @longmaba.
|
||||||
|
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ dependencies {
|
|||||||
testImplementation("io.kotest:kotest-runner-junit5-jvm:6.0.7")
|
testImplementation("io.kotest:kotest-runner-junit5-jvm:6.0.7")
|
||||||
testImplementation("io.kotest:kotest-assertions-core-jvm:6.0.7")
|
testImplementation("io.kotest:kotest-assertions-core-jvm:6.0.7")
|
||||||
testImplementation("org.robolectric:robolectric:4.16")
|
testImplementation("org.robolectric:robolectric:4.16")
|
||||||
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.1")
|
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.2")
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.withType<Test>().configureEach {
|
tasks.withType<Test>().configureEach {
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ class BridgeSession(
|
|||||||
connectWithSocket(endpoint, hello, null)
|
connectWithSocket(endpoint, hello, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun connectWithSocket(endpoint: BridgeEndpoint, hello: Hello, tls: BridgeTlsParams?) {
|
private suspend fun connectWithSocket(endpoint: BridgeEndpoint, hello: Hello, tls: BridgeTlsParams?) {
|
||||||
val socket =
|
val socket =
|
||||||
createBridgeSocket(tls) { fingerprint ->
|
createBridgeSocket(tls) { fingerprint ->
|
||||||
onTlsFingerprint?.invoke(tls?.stableId ?: endpoint.stableId, fingerprint)
|
onTlsFingerprint?.invoke(tls?.stableId ?: endpoint.stableId, fingerprint)
|
||||||
@@ -260,15 +260,15 @@ class BridgeSession(
|
|||||||
else -> throw IllegalStateException("unexpected bridge response")
|
else -> throw IllegalStateException("unexpected bridge response")
|
||||||
}
|
}
|
||||||
|
|
||||||
while (scope.isActive) {
|
while (scope.isActive) {
|
||||||
val line = reader.readLine() ?: break
|
val line = reader.readLine() ?: break
|
||||||
val frame = json.parseToJsonElement(line).asObjectOrNull() ?: continue
|
val frame = json.parseToJsonElement(line).asObjectOrNull() ?: continue
|
||||||
when (frame["type"].asStringOrNull()) {
|
when (frame["type"].asStringOrNull()) {
|
||||||
"event" -> {
|
"event" -> {
|
||||||
val event = frame["event"].asStringOrNull() ?: return@withContext
|
val event = frame["event"].asStringOrNull() ?: continue
|
||||||
val payload = frame["payloadJSON"].asStringOrNull()
|
val payload = frame["payloadJSON"].asStringOrNull()
|
||||||
onEvent(event, payload)
|
onEvent(event, payload)
|
||||||
}
|
}
|
||||||
"ping" -> {
|
"ping" -> {
|
||||||
val id = frame["id"].asStringOrNull() ?: ""
|
val id = frame["id"].asStringOrNull() ?: ""
|
||||||
conn.sendJson(buildJsonObject { put("type", JsonPrimitive("pong")); put("id", JsonPrimitive(id)) })
|
conn.sendJson(buildJsonObject { put("type", JsonPrimitive("pong")); put("id", JsonPrimitive(id)) })
|
||||||
@@ -314,20 +314,20 @@ class BridgeSession(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
"invoke-res" -> {
|
"invoke-res" -> {
|
||||||
// gateway->node only (ignore)
|
// gateway->node only (ignore)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
currentConnection = null
|
|
||||||
for ((_, waiter) in pending) {
|
|
||||||
waiter.cancel()
|
|
||||||
}
|
|
||||||
pending.clear()
|
|
||||||
conn.closeQuietly()
|
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
currentConnection = null
|
||||||
|
for ((_, waiter) in pending) {
|
||||||
|
waiter.cancel()
|
||||||
|
}
|
||||||
|
pending.clear()
|
||||||
|
conn.closeQuietly()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun buildHelloJson(hello: Hello): JsonObject =
|
private fun buildHelloJson(hello: Hello): JsonObject =
|
||||||
buildJsonObject {
|
buildJsonObject {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.clawdbot.android.bridge
|
package com.clawdbot.android.bridge
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import java.net.Socket
|
import java.net.Socket
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
@@ -21,6 +22,7 @@ fun createBridgeSocket(params: BridgeTlsParams?, onStore: ((String) -> Unit)? =
|
|||||||
if (params == null) return Socket()
|
if (params == null) return Socket()
|
||||||
val expected = params.expectedFingerprint?.let(::normalizeFingerprint)
|
val expected = params.expectedFingerprint?.let(::normalizeFingerprint)
|
||||||
val defaultTrust = defaultTrustManager()
|
val defaultTrust = defaultTrustManager()
|
||||||
|
@SuppressLint("CustomX509TrustManager")
|
||||||
val trustManager =
|
val trustManager =
|
||||||
object : X509TrustManager {
|
object : X509TrustManager {
|
||||||
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {
|
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {
|
||||||
|
|||||||
@@ -32,11 +32,10 @@ func makeBridgeTLSOptions(_ params: BridgeTLSParams?) -> NWProtocolTLS.Options?
|
|||||||
sec_protocol_options_set_verify_block(
|
sec_protocol_options_set_verify_block(
|
||||||
options.securityProtocolOptions,
|
options.securityProtocolOptions,
|
||||||
{ _, trust, complete in
|
{ _, trust, complete in
|
||||||
guard let trust else {
|
let trustRef = sec_trust_copy_ref(trust).takeRetainedValue()
|
||||||
complete(false)
|
if let chain = SecTrustCopyCertificateChain(trustRef) as? [SecCertificate],
|
||||||
return
|
let cert = chain.first
|
||||||
}
|
{
|
||||||
if let cert = SecTrustGetCertificateAtIndex(trust, 0) {
|
|
||||||
let data = SecCertificateCopyData(cert) as Data
|
let data = SecCertificateCopyData(cert) as Data
|
||||||
let fingerprint = sha256Hex(data)
|
let fingerprint = sha256Hex(data)
|
||||||
if let expected {
|
if let expected {
|
||||||
@@ -49,7 +48,7 @@ func makeBridgeTLSOptions(_ params: BridgeTLSParams?) -> NWProtocolTLS.Options?
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let ok = SecTrustEvaluateWithError(trust, nil)
|
let ok = SecTrustEvaluateWithError(trustRef, nil)
|
||||||
complete(ok)
|
complete(ok)
|
||||||
},
|
},
|
||||||
DispatchQueue(label: "com.clawdbot.bridge.tls.verify"))
|
DispatchQueue(label: "com.clawdbot.bridge.tls.verify"))
|
||||||
|
|||||||
@@ -190,14 +190,7 @@ actor CameraController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func listDevices() -> [CameraDeviceInfo] {
|
func listDevices() -> [CameraDeviceInfo] {
|
||||||
let types: [AVCaptureDevice.DeviceType] = [
|
return Self.discoverVideoDevices().map { device in
|
||||||
.builtInWideAngleCamera,
|
|
||||||
]
|
|
||||||
let session = AVCaptureDevice.DiscoverySession(
|
|
||||||
deviceTypes: types,
|
|
||||||
mediaType: .video,
|
|
||||||
position: .unspecified)
|
|
||||||
return session.devices.map { device in
|
|
||||||
CameraDeviceInfo(
|
CameraDeviceInfo(
|
||||||
id: device.uniqueID,
|
id: device.uniqueID,
|
||||||
name: device.localizedName,
|
name: device.localizedName,
|
||||||
@@ -232,7 +225,7 @@ actor CameraController {
|
|||||||
deviceId: String?) -> AVCaptureDevice?
|
deviceId: String?) -> AVCaptureDevice?
|
||||||
{
|
{
|
||||||
if let deviceId, !deviceId.isEmpty {
|
if let deviceId, !deviceId.isEmpty {
|
||||||
if let match = AVCaptureDevice.devices(for: .video).first(where: { $0.uniqueID == deviceId }) {
|
if let match = Self.discoverVideoDevices().first(where: { $0.uniqueID == deviceId }) {
|
||||||
return match
|
return match
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -252,6 +245,24 @@ actor CameraController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private nonisolated static func discoverVideoDevices() -> [AVCaptureDevice] {
|
||||||
|
let types: [AVCaptureDevice.DeviceType] = [
|
||||||
|
.builtInWideAngleCamera,
|
||||||
|
.builtInUltraWideCamera,
|
||||||
|
.builtInTelephotoCamera,
|
||||||
|
.builtInDualCamera,
|
||||||
|
.builtInDualWideCamera,
|
||||||
|
.builtInTripleCamera,
|
||||||
|
.builtInTrueDepthCamera,
|
||||||
|
.builtInLiDARDepthCamera,
|
||||||
|
]
|
||||||
|
let session = AVCaptureDevice.DiscoverySession(
|
||||||
|
deviceTypes: types,
|
||||||
|
mediaType: .video,
|
||||||
|
position: .unspecified)
|
||||||
|
return session.devices
|
||||||
|
}
|
||||||
|
|
||||||
nonisolated static func clampQuality(_ quality: Double?) -> Double {
|
nonisolated static func clampQuality(_ quality: Double?) -> Double {
|
||||||
let q = quality ?? 0.9
|
let q = quality ?? 0.9
|
||||||
return min(1.0, max(0.05, q))
|
return min(1.0, max(0.05, q))
|
||||||
|
|||||||
@@ -137,9 +137,11 @@ final class ScreenRecordService: @unchecked Sendable {
|
|||||||
recordQueue: DispatchQueue) -> @Sendable (CMSampleBuffer, RPSampleBufferType, Error?) -> Void
|
recordQueue: DispatchQueue) -> @Sendable (CMSampleBuffer, RPSampleBufferType, Error?) -> Void
|
||||||
{
|
{
|
||||||
{ sample, type, error in
|
{ sample, type, error in
|
||||||
|
let sampleBox = UncheckedSendableBox(value: sample)
|
||||||
// ReplayKit can call the capture handler on a background queue.
|
// ReplayKit can call the capture handler on a background queue.
|
||||||
// Serialize writes to avoid queue asserts.
|
// Serialize writes to avoid queue asserts.
|
||||||
recordQueue.async {
|
recordQueue.async {
|
||||||
|
let sample = sampleBox.value
|
||||||
if let error {
|
if let error {
|
||||||
state.withLock { state in
|
state.withLock { state in
|
||||||
if state.handlerError == nil { state.handlerError = error }
|
if state.handlerError == nil { state.handlerError = error }
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ enum GatewayEnvironment {
|
|||||||
let port = self.gatewayPort()
|
let port = self.gatewayPort()
|
||||||
if let gatewayBin {
|
if let gatewayBin {
|
||||||
let bind = self.preferredGatewayBind() ?? "loopback"
|
let bind = self.preferredGatewayBind() ?? "loopback"
|
||||||
let cmd = [gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind]
|
let cmd = [gatewayBin, "gateway", "--port", "\(port)", "--bind", bind]
|
||||||
return GatewayCommandResolution(status: status, command: cmd)
|
return GatewayCommandResolution(status: status, command: cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,7 +186,7 @@ enum GatewayEnvironment {
|
|||||||
case let .success(resolvedRuntime) = runtime
|
case let .success(resolvedRuntime) = runtime
|
||||||
{
|
{
|
||||||
let bind = self.preferredGatewayBind() ?? "loopback"
|
let bind = self.preferredGatewayBind() ?? "loopback"
|
||||||
let cmd = [resolvedRuntime.path, entry, "gateway-daemon", "--port", "\(port)", "--bind", bind]
|
let cmd = [resolvedRuntime.path, entry, "gateway", "--port", "\(port)", "--bind", bind]
|
||||||
return GatewayCommandResolution(status: status, command: cmd)
|
return GatewayCommandResolution(status: status, command: cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,10 @@ extension GatewayLaunchAgentManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func readDaemonLoaded() async -> Bool? {
|
private static func readDaemonLoaded() async -> Bool? {
|
||||||
let result = await self.runDaemonCommand(["status", "--json", "--no-probe"], timeout: 15, quiet: true)
|
let result = await self.runDaemonCommandResult(
|
||||||
|
["status", "--json", "--no-probe"],
|
||||||
|
timeout: 15,
|
||||||
|
quiet: true)
|
||||||
guard result.success, let payload = result.payload else { return nil }
|
guard result.success, let payload = result.payload else { return nil }
|
||||||
guard
|
guard
|
||||||
let json = try? JSONSerialization.jsonObject(with: payload) as? [String: Any],
|
let json = try? JSONSerialization.jsonObject(with: payload) as? [String: Any],
|
||||||
|
|||||||
@@ -87,6 +87,14 @@ final class GatewayProcessManager {
|
|||||||
self.status = .stopped
|
self.status = .stopped
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Many surfaces can call `setActive(true)` in quick succession (startup, Canvas, health checks).
|
||||||
|
// Avoid spawning multiple concurrent "start" tasks that can thrash launchd and flap the port.
|
||||||
|
switch self.status {
|
||||||
|
case .starting, .running, .attachedExisting:
|
||||||
|
return
|
||||||
|
case .stopped, .failed:
|
||||||
|
break
|
||||||
|
}
|
||||||
self.status = .starting
|
self.status = .starting
|
||||||
self.logger.debug("gateway start requested")
|
self.logger.debug("gateway start requested")
|
||||||
|
|
||||||
|
|||||||
@@ -92,10 +92,13 @@ struct GeneralSettings: View {
|
|||||||
Text(policy.title).tag(policy)
|
Text(policy.title).tag(policy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
.labelsHidden()
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
|
||||||
Text("""
|
Text("""
|
||||||
Controls remote command execution on this Mac when it is paired as a node. "Always Ask" prompts on each command; "Always Allow" runs without prompts; "Never" disables `system.run`.
|
Controls remote command execution on this Mac when it is paired as a node. \
|
||||||
|
"Always Ask" prompts on each command; "Always Allow" runs without prompts; \
|
||||||
|
"Never" disables `system.run`.
|
||||||
""")
|
""")
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.tertiary)
|
.foregroundStyle(.tertiary)
|
||||||
@@ -111,7 +114,8 @@ struct GeneralSettings: View {
|
|||||||
Text("While Using").tag(ClawdbotLocationMode.whileUsing.rawValue)
|
Text("While Using").tag(ClawdbotLocationMode.whileUsing.rawValue)
|
||||||
Text("Always").tag(ClawdbotLocationMode.always.rawValue)
|
Text("Always").tag(ClawdbotLocationMode.always.rawValue)
|
||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
.labelsHidden()
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
|
||||||
Toggle("Precise Location", isOn: self.$locationPreciseEnabled)
|
Toggle("Precise Location", isOn: self.$locationPreciseEnabled)
|
||||||
.disabled(self.locationMode == .off)
|
.disabled(self.locationMode == .off)
|
||||||
|
|||||||
@@ -460,7 +460,7 @@ actor MacNodeBridgeSession {
|
|||||||
do {
|
do {
|
||||||
try await self.send(response)
|
try await self.send(response)
|
||||||
} catch {
|
} catch {
|
||||||
await self.logInvokeSendFailure(error)
|
self.logInvokeSendFailure(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,14 +19,14 @@ enum MacNodeBridgeTLSStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func loadFingerprint(stableID: String) -> String? {
|
static func loadFingerprint(stableID: String) -> String? {
|
||||||
let key = keyPrefix + stableID
|
let key = self.keyPrefix + stableID
|
||||||
let raw = defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
let raw = self.defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
return raw?.isEmpty == false ? raw : nil
|
return raw?.isEmpty == false ? raw : nil
|
||||||
}
|
}
|
||||||
|
|
||||||
static func saveFingerprint(_ value: String, stableID: String) {
|
static func saveFingerprint(_ value: String, stableID: String) {
|
||||||
let key = keyPrefix + stableID
|
let key = self.keyPrefix + stableID
|
||||||
defaults.set(value, forKey: key)
|
self.defaults.set(value, forKey: key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,11 +40,10 @@ func makeMacNodeTLSOptions(_ params: MacNodeBridgeTLSParams?) -> NWProtocolTLS.O
|
|||||||
sec_protocol_options_set_verify_block(
|
sec_protocol_options_set_verify_block(
|
||||||
options.securityProtocolOptions,
|
options.securityProtocolOptions,
|
||||||
{ _, trust, complete in
|
{ _, trust, complete in
|
||||||
guard let trust else {
|
let trustRef = sec_trust_copy_ref(trust).takeRetainedValue()
|
||||||
complete(false)
|
if let chain = SecTrustCopyCertificateChain(trustRef) as? [SecCertificate],
|
||||||
return
|
let cert = chain.first
|
||||||
}
|
{
|
||||||
if let cert = SecTrustGetCertificateAtIndex(trust, 0) {
|
|
||||||
let data = SecCertificateCopyData(cert) as Data
|
let data = SecCertificateCopyData(cert) as Data
|
||||||
let fingerprint = sha256Hex(data)
|
let fingerprint = sha256Hex(data)
|
||||||
if let expected {
|
if let expected {
|
||||||
@@ -57,7 +56,7 @@ func makeMacNodeTLSOptions(_ params: MacNodeBridgeTLSParams?) -> NWProtocolTLS.O
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let ok = SecTrustEvaluateWithError(trust, nil)
|
let ok = SecTrustEvaluateWithError(trustRef, nil)
|
||||||
complete(ok)
|
complete(ok)
|
||||||
},
|
},
|
||||||
DispatchQueue(label: "com.clawdbot.macos.bridge.tls.verify"))
|
DispatchQueue(label: "com.clawdbot.macos.bridge.tls.verify"))
|
||||||
@@ -71,5 +70,5 @@ private func sha256Hex(_ data: Data) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func normalizeMacNodeFingerprint(_ raw: String) -> String {
|
private func normalizeMacNodeFingerprint(_ raw: String) -> String {
|
||||||
raw.lowercased().filter { $0.isHexDigit }
|
raw.lowercased().filter(\.isHexDigit)
|
||||||
}
|
}
|
||||||
@@ -463,7 +463,7 @@ final class MacNodeModeCoordinator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func targetFromResult(_ result: NWBrowser.Result) -> BridgeTarget? {
|
private nonisolated static func targetFromResult(_ result: NWBrowser.Result) -> BridgeTarget? {
|
||||||
let endpoint = result.endpoint
|
let endpoint = result.endpoint
|
||||||
guard case .service = endpoint else { return nil }
|
guard case .service = endpoint else { return nil }
|
||||||
let stableID = BridgeEndpointID.stableID(endpoint)
|
let stableID = BridgeEndpointID.stableID(endpoint)
|
||||||
@@ -477,7 +477,7 @@ final class MacNodeModeCoordinator {
|
|||||||
return BridgeTarget(endpoint: endpoint, stableID: stableID, tls: tlsParams)
|
return BridgeTarget(endpoint: endpoint, stableID: stableID, tls: tlsParams)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func resolveDiscoveredTLSParams(
|
private nonisolated static func resolveDiscoveredTLSParams(
|
||||||
stableID: String,
|
stableID: String,
|
||||||
tlsEnabled: Bool,
|
tlsEnabled: Bool,
|
||||||
tlsFingerprintSha256: String?) -> MacNodeBridgeTLSParams?
|
tlsFingerprintSha256: String?) -> MacNodeBridgeTLSParams?
|
||||||
@@ -503,7 +503,7 @@ final class MacNodeModeCoordinator {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func resolveManualTLSParams(stableID: String) -> MacNodeBridgeTLSParams? {
|
private nonisolated static func resolveManualTLSParams(stableID: String) -> MacNodeBridgeTLSParams? {
|
||||||
if let stored = MacNodeBridgeTLSStore.loadFingerprint(stableID: stableID) {
|
if let stored = MacNodeBridgeTLSStore.loadFingerprint(stableID: stableID) {
|
||||||
return MacNodeBridgeTLSParams(
|
return MacNodeBridgeTLSParams(
|
||||||
required: true,
|
required: true,
|
||||||
@@ -519,12 +519,12 @@ final class MacNodeModeCoordinator {
|
|||||||
storeKey: stableID)
|
storeKey: stableID)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func txtValue(_ dict: [String: String], key: String) -> String? {
|
private nonisolated static func txtValue(_ dict: [String: String], key: String) -> String? {
|
||||||
let raw = dict[key]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
let raw = dict[key]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
return raw.isEmpty ? nil : raw
|
return raw.isEmpty ? nil : raw
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func txtBoolValue(_ dict: [String: String], key: String) -> Bool {
|
private nonisolated static func txtBoolValue(_ dict: [String: String], key: String) -> Bool {
|
||||||
guard let raw = self.txtValue(dict, key: key)?.lowercased() else { return false }
|
guard let raw = self.txtValue(dict, key: key)?.lowercased() else { return false }
|
||||||
return raw == "1" || raw == "true" || raw == "yes"
|
return raw == "1" || raw == "true" || raw == "yes"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -586,8 +586,8 @@ actor MacNodeRuntime {
|
|||||||
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !key.isEmpty else { continue }
|
guard !key.isEmpty else { continue }
|
||||||
let upper = key.uppercased()
|
let upper = key.uppercased()
|
||||||
if blockedEnvKeys.contains(upper) { continue }
|
if self.blockedEnvKeys.contains(upper) { continue }
|
||||||
if blockedEnvPrefixes.contains(where: { upper.hasPrefix($0) }) { continue }
|
if self.blockedEnvPrefixes.contains(where: { upper.hasPrefix($0) }) { continue }
|
||||||
merged[key] = value
|
merged[key] = value
|
||||||
}
|
}
|
||||||
return merged
|
return merged
|
||||||
|
|||||||
@@ -351,10 +351,14 @@ actor PortGuardian {
|
|||||||
if port == GatewayEnvironment.gatewayPort() { return cmd.contains("ssh") }
|
if port == GatewayEnvironment.gatewayPort() { return cmd.contains("ssh") }
|
||||||
return false
|
return false
|
||||||
case .local:
|
case .local:
|
||||||
if !cmd.contains("clawdbot") { return false }
|
// The gateway may listen as `clawdbot` or via its runtime (e.g. node).
|
||||||
if full.contains("gateway-daemon") { return true }
|
let hasGatewayArgs =
|
||||||
|
full.contains(" gateway ") &&
|
||||||
|
full.contains(" --port \(port)")
|
||||||
|
if hasGatewayArgs { return true }
|
||||||
// If args are unavailable, treat a clawdbot listener as expected.
|
// If args are unavailable, treat a clawdbot listener as expected.
|
||||||
return full == cmd
|
if cmd.contains("clawdbot"), full == cmd { return true }
|
||||||
|
return false
|
||||||
case .unconfigured:
|
case .unconfigured:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ enum SystemRunPolicy: String, CaseIterable, Identifiable {
|
|||||||
var title: String {
|
var title: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .never:
|
case .never:
|
||||||
return "Never"
|
"Never"
|
||||||
case .ask:
|
case .ask:
|
||||||
return "Always Ask"
|
"Always Ask"
|
||||||
case .always:
|
case .always:
|
||||||
return "Always Allow"
|
"Always Allow"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,13 +75,13 @@ enum SystemRunAllowlist {
|
|||||||
|
|
||||||
static func contains(_ argv: [String], defaults: UserDefaults = .standard) -> Bool {
|
static func contains(_ argv: [String], defaults: UserDefaults = .standard) -> Bool {
|
||||||
let key = key(for: argv)
|
let key = key(for: argv)
|
||||||
return load(from: defaults).contains(key)
|
return self.load(from: defaults).contains(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func add(_ argv: [String], defaults: UserDefaults = .standard) {
|
static func add(_ argv: [String], defaults: UserDefaults = .standard) {
|
||||||
let key = key(for: argv)
|
let key = key(for: argv)
|
||||||
guard !key.isEmpty else { return }
|
guard !key.isEmpty else { return }
|
||||||
var allowlist = load(from: defaults)
|
var allowlist = self.load(from: defaults)
|
||||||
if allowlist.insert(key).inserted {
|
if allowlist.insert(key).inserted {
|
||||||
MacNodeConfigFile.setSystemRunAllowlist(Array(allowlist).sorted())
|
MacNodeConfigFile.setSystemRunAllowlist(Array(allowlist).sorted())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import Testing
|
|||||||
let url = FileManager.default.temporaryDirectory
|
let url = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-launchd-\(UUID().uuidString).plist")
|
.appendingPathComponent("clawdbot-launchd-\(UUID().uuidString).plist")
|
||||||
let plist: [String: Any] = [
|
let plist: [String: Any] = [
|
||||||
"ProgramArguments": ["clawdbot", "gateway-daemon", "--port", "18789", "--bind", "loopback"],
|
"ProgramArguments": ["clawdbot", "gateway", "--port", "18789", "--bind", "loopback"],
|
||||||
"EnvironmentVariables": [
|
"EnvironmentVariables": [
|
||||||
"CLAWDBOT_GATEWAY_TOKEN": " secret ",
|
"CLAWDBOT_GATEWAY_TOKEN": " secret ",
|
||||||
"CLAWDBOT_GATEWAY_PASSWORD": "pw",
|
"CLAWDBOT_GATEWAY_PASSWORD": "pw",
|
||||||
@@ -28,7 +28,7 @@ import Testing
|
|||||||
let url = FileManager.default.temporaryDirectory
|
let url = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent("clawdbot-launchd-\(UUID().uuidString).plist")
|
.appendingPathComponent("clawdbot-launchd-\(UUID().uuidString).plist")
|
||||||
let plist: [String: Any] = [
|
let plist: [String: Any] = [
|
||||||
"ProgramArguments": ["clawdbot", "gateway-daemon", "--port", "18789"],
|
"ProgramArguments": ["clawdbot", "gateway", "--port", "18789"],
|
||||||
]
|
]
|
||||||
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
|
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
|
||||||
try data.write(to: url, options: [.atomic])
|
try data.write(to: url, options: [.atomic])
|
||||||
|
|||||||
@@ -227,6 +227,8 @@ Outbound Discord API calls retry on rate limits (429) using Discord `retry_after
|
|||||||
actions: {
|
actions: {
|
||||||
reactions: true,
|
reactions: true,
|
||||||
stickers: true,
|
stickers: true,
|
||||||
|
emojiUploads: true,
|
||||||
|
stickerUploads: true,
|
||||||
polls: true,
|
polls: true,
|
||||||
permissions: true,
|
permissions: true,
|
||||||
messages: true,
|
messages: true,
|
||||||
@@ -237,6 +239,7 @@ Outbound Discord API calls retry on rate limits (429) using Discord `retry_after
|
|||||||
roleInfo: true,
|
roleInfo: true,
|
||||||
roles: false,
|
roles: false,
|
||||||
channelInfo: true,
|
channelInfo: true,
|
||||||
|
channels: true,
|
||||||
voiceStatus: true,
|
voiceStatus: true,
|
||||||
events: true,
|
events: true,
|
||||||
moderation: false
|
moderation: false
|
||||||
@@ -304,8 +307,9 @@ ack reaction after the bot replies.
|
|||||||
- `retry`: retry policy for outbound Discord API calls (attempts, minDelayMs, maxDelayMs, jitter).
|
- `retry`: retry policy for outbound Discord API calls (attempts, minDelayMs, maxDelayMs, jitter).
|
||||||
- `actions`: per-action tool gates; omit to allow all (set `false` to disable).
|
- `actions`: per-action tool gates; omit to allow all (set `false` to disable).
|
||||||
- `reactions` (covers react + read reactions)
|
- `reactions` (covers react + read reactions)
|
||||||
- `stickers`, `polls`, `permissions`, `messages`, `threads`, `pins`, `search`
|
- `stickers`, `emojiUploads`, `stickerUploads`, `polls`, `permissions`, `messages`, `threads`, `pins`, `search`
|
||||||
- `memberInfo`, `roleInfo`, `channelInfo`, `voiceStatus`, `events`
|
- `memberInfo`, `roleInfo`, `channelInfo`, `voiceStatus`, `events`
|
||||||
|
- `channels` (create/edit/delete channels + categories + permissions)
|
||||||
- `roles` (role add/remove, default `false`)
|
- `roles` (role add/remove, default `false`)
|
||||||
- `moderation` (timeout/kick/ban, default `false`)
|
- `moderation` (timeout/kick/ban, default `false`)
|
||||||
|
|
||||||
@@ -321,6 +325,8 @@ Reaction notifications use `guilds.<id>.reactionNotifications`:
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| reactions | enabled | React + list reactions + emojiList |
|
| reactions | enabled | React + list reactions + emojiList |
|
||||||
| stickers | enabled | Send stickers |
|
| stickers | enabled | Send stickers |
|
||||||
|
| emojiUploads | enabled | Upload emojis |
|
||||||
|
| stickerUploads | enabled | Upload stickers |
|
||||||
| polls | enabled | Create polls |
|
| polls | enabled | Create polls |
|
||||||
| permissions | enabled | Channel permission snapshot |
|
| permissions | enabled | Channel permission snapshot |
|
||||||
| messages | enabled | Read/send/edit/delete |
|
| messages | enabled | Read/send/edit/delete |
|
||||||
@@ -330,6 +336,7 @@ Reaction notifications use `guilds.<id>.reactionNotifications`:
|
|||||||
| memberInfo | enabled | Member info |
|
| memberInfo | enabled | Member info |
|
||||||
| roleInfo | enabled | Role list |
|
| roleInfo | enabled | Role list |
|
||||||
| channelInfo | enabled | Channel info + list |
|
| channelInfo | enabled | Channel info + list |
|
||||||
|
| channels | enabled | Channel/category management |
|
||||||
| voiceStatus | enabled | Voice state lookup |
|
| voiceStatus | enabled | Voice state lookup |
|
||||||
| events | enabled | List/create scheduled events |
|
| events | enabled | List/create scheduled events |
|
||||||
| roles | disabled | Role add/remove |
|
| roles | disabled | Role add/remove |
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
summary: "CLI reference for `clawdbot config` (get/set/unset config values)"
|
||||||
|
read_when:
|
||||||
|
- You want to read or edit config non-interactively
|
||||||
|
---
|
||||||
|
|
||||||
|
# `clawdbot config`
|
||||||
|
|
||||||
|
Config helpers: get/set/unset values by path. Run without a subcommand to open
|
||||||
|
the configure wizard (same as `clawdbot configure`).
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot config get browser.executablePath
|
||||||
|
clawdbot config set browser.executablePath "/usr/bin/google-chrome"
|
||||||
|
clawdbot config set agents.defaults.heartbeat.every "2h"
|
||||||
|
clawdbot config unset tools.web.search.apiKey
|
||||||
|
```
|
||||||
|
|
||||||
|
## Paths
|
||||||
|
|
||||||
|
Paths use dot or bracket notation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot config get agents.defaults.workspace
|
||||||
|
clawdbot config get agents.list[0].id
|
||||||
|
```
|
||||||
|
|
||||||
|
## Values
|
||||||
|
|
||||||
|
Values are parsed as JSON5 when possible; otherwise they are treated as strings.
|
||||||
|
Use `--json` to require JSON5 parsing.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot config set agents.defaults.heartbeat.every "0m"
|
||||||
|
clawdbot config set gateway.port 19001 --json
|
||||||
|
clawdbot config set channels.whatsapp.groups '["*"]' --json
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart the gateway after edits.
|
||||||
@@ -1,15 +1,19 @@
|
|||||||
---
|
---
|
||||||
summary: "CLI reference for `clawdbot configure` / `clawdbot config` (interactive configuration prompts)"
|
summary: "CLI reference for `clawdbot configure` (interactive configuration prompts)"
|
||||||
read_when:
|
read_when:
|
||||||
- You want to tweak credentials, devices, or agent defaults interactively
|
- You want to tweak credentials, devices, or agent defaults interactively
|
||||||
---
|
---
|
||||||
|
|
||||||
# `clawdbot configure` (alias: `config`)
|
# `clawdbot configure`
|
||||||
|
|
||||||
Interactive prompt to set up credentials, devices, and agent defaults.
|
Interactive prompt to set up credentials, devices, and agent defaults.
|
||||||
|
|
||||||
|
Tip: `clawdbot config` without a subcommand opens the same wizard. Use
|
||||||
|
`clawdbot config get|set|unset` for non-interactive edits.
|
||||||
|
|
||||||
Related:
|
Related:
|
||||||
- Gateway configuration reference: [Configuration](/gateway/configuration)
|
- Gateway configuration reference: [Configuration](/gateway/configuration)
|
||||||
|
- Config CLI: [Config](/cli/config)
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- Choosing where the Gateway runs always updates `gateway.mode`. You can select "Continue" without other sections if that is all you need.
|
- Choosing where the Gateway runs always updates `gateway.mode`. You can select "Continue" without other sections if that is all you need.
|
||||||
|
|||||||
+17
-3
@@ -13,7 +13,8 @@ This page describes the current CLI behavior. If commands change, update this do
|
|||||||
|
|
||||||
- [`setup`](/cli/setup)
|
- [`setup`](/cli/setup)
|
||||||
- [`onboard`](/cli/onboard)
|
- [`onboard`](/cli/onboard)
|
||||||
- [`configure`](/cli/configure) (alias: `config`)
|
- [`configure`](/cli/configure)
|
||||||
|
- [`config`](/cli/config)
|
||||||
- [`doctor`](/cli/doctor)
|
- [`doctor`](/cli/doctor)
|
||||||
- [`dashboard`](/cli/dashboard)
|
- [`dashboard`](/cli/dashboard)
|
||||||
- [`reset`](/cli/reset)
|
- [`reset`](/cli/reset)
|
||||||
@@ -83,7 +84,11 @@ Palette source of truth: `src/terminal/palette.ts` (aka “lobster seam”).
|
|||||||
clawdbot [--dev] [--profile <name>] <command>
|
clawdbot [--dev] [--profile <name>] <command>
|
||||||
setup
|
setup
|
||||||
onboard
|
onboard
|
||||||
configure (alias: config)
|
configure
|
||||||
|
config
|
||||||
|
get
|
||||||
|
set
|
||||||
|
unset
|
||||||
doctor
|
doctor
|
||||||
security
|
security
|
||||||
audit
|
audit
|
||||||
@@ -310,9 +315,18 @@ Options:
|
|||||||
- `--node-manager <npm|pnpm|bun>` (pnpm recommended; bun not recommended for Gateway runtime)
|
- `--node-manager <npm|pnpm|bun>` (pnpm recommended; bun not recommended for Gateway runtime)
|
||||||
- `--json`
|
- `--json`
|
||||||
|
|
||||||
### `configure` / `config`
|
### `configure`
|
||||||
Interactive configuration wizard (models, channels, skills, gateway).
|
Interactive configuration wizard (models, channels, skills, gateway).
|
||||||
|
|
||||||
|
### `config`
|
||||||
|
Non-interactive config helpers (get/set/unset). Running `clawdbot config` with no
|
||||||
|
subcommand launches the wizard.
|
||||||
|
|
||||||
|
Subcommands:
|
||||||
|
- `config get <path>`: print a config value (dot/bracket path).
|
||||||
|
- `config set <path> <value>`: set a value (JSON5 or raw string).
|
||||||
|
- `config unset <path>`: remove a value.
|
||||||
|
|
||||||
### `doctor`
|
### `doctor`
|
||||||
Health checks + quick fixes (config + gateway + legacy services).
|
Health checks + quick fixes (config + gateway + legacy services).
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ clawdbot plugins info <id>
|
|||||||
clawdbot plugins enable <id>
|
clawdbot plugins enable <id>
|
||||||
clawdbot plugins disable <id>
|
clawdbot plugins disable <id>
|
||||||
clawdbot plugins doctor
|
clawdbot plugins doctor
|
||||||
|
clawdbot plugins update <id>
|
||||||
|
clawdbot plugins update --all
|
||||||
```
|
```
|
||||||
|
|
||||||
### Install
|
### Install
|
||||||
@@ -31,3 +33,12 @@ clawdbot plugins install <npm-spec>
|
|||||||
|
|
||||||
Security note: treat plugin installs like running code. Prefer pinned versions.
|
Security note: treat plugin installs like running code. Prefer pinned versions.
|
||||||
|
|
||||||
|
### Update
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot plugins update <id>
|
||||||
|
clawdbot plugins update --all
|
||||||
|
clawdbot plugins update <id> --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
Updates only apply to plugins installed from npm (tracked in `plugins.installs`).
|
||||||
@@ -169,6 +169,20 @@ The agent was interrupted mid-response.
|
|||||||
|
|
||||||
**Fix:** Just send another message. The session continues.
|
**Fix:** Just send another message. The session continues.
|
||||||
|
|
||||||
|
### "Agent failed before reply: Unknown model: anthropic/claude-haiku-3-5"
|
||||||
|
|
||||||
|
Clawdbot intentionally rejects **older/insecure models** (especially those more
|
||||||
|
vulnerable to prompt injection). If you see this error, the model name is no
|
||||||
|
longer supported.
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
- Pick a **latest** model for the provider and update your config or model alias.
|
||||||
|
- If you’re unsure which models are available, run `clawdbot models list` or
|
||||||
|
`clawdbot models scan` and choose a supported one.
|
||||||
|
- Check gateway logs for the detailed failure reason.
|
||||||
|
|
||||||
|
See also: [Models CLI](/cli/models) and [Model providers](/concepts/model-providers).
|
||||||
|
|
||||||
### Messages Not Triggering
|
### Messages Not Triggering
|
||||||
|
|
||||||
**Check 1:** Is the sender allowlisted?
|
**Check 1:** Is the sender allowlisted?
|
||||||
@@ -306,6 +320,139 @@ Clawdbot keeps conversation history in memory.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Common troubleshooting
|
||||||
|
|
||||||
|
### “All models failed” — what should I check first?
|
||||||
|
|
||||||
|
- **Credentials** present for the provider(s) being tried (auth profiles + env vars).
|
||||||
|
- **Model routing**: confirm `agents.defaults.model.primary` and fallbacks are models you can access.
|
||||||
|
- **Gateway logs** in `/tmp/clawdbot/…` for the exact provider error.
|
||||||
|
- **Model status**: use `/model status` (chat) or `clawdbot models status` (CLI).
|
||||||
|
|
||||||
|
### I’m running on my personal WhatsApp number — why is self-chat weird?
|
||||||
|
|
||||||
|
Enable self-chat mode and allowlist your own number:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
whatsapp: {
|
||||||
|
selfChatMode: true,
|
||||||
|
dmPolicy: "allowlist",
|
||||||
|
allowFrom: ["+15555550123"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See [WhatsApp setup](/channels/whatsapp).
|
||||||
|
|
||||||
|
### WhatsApp logged me out. How do I re‑auth?
|
||||||
|
|
||||||
|
Run the login command again and scan the QR code:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot channels login
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build errors on `main` — what’s the standard fix path?
|
||||||
|
|
||||||
|
1) `git pull origin main && pnpm install`
|
||||||
|
2) `pnpm clawdbot doctor`
|
||||||
|
3) Check GitHub issues or Discord
|
||||||
|
4) Temporary workaround: check out an older commit
|
||||||
|
|
||||||
|
### npm install fails (allow-build-scripts / missing tar or yargs). What now?
|
||||||
|
|
||||||
|
If you’re running from source, use the repo’s package manager: **pnpm** (preferred).
|
||||||
|
The repo declares `packageManager: "pnpm@…"`, and pnpm patches are tracked in `pnpm.patchedDependencies`.
|
||||||
|
|
||||||
|
Typical recovery:
|
||||||
|
```bash
|
||||||
|
git status # ensure you’re in the repo root
|
||||||
|
pnpm install
|
||||||
|
pnpm build
|
||||||
|
pnpm clawdbot doctor
|
||||||
|
clawdbot daemon restart
|
||||||
|
```
|
||||||
|
|
||||||
|
Why: pnpm is the configured package manager for this repo, and the dependency
|
||||||
|
patching workflow relies on it.
|
||||||
|
|
||||||
|
### How do I switch between git installs and npm installs?
|
||||||
|
|
||||||
|
Use the **website installer** and select the install method with a flag. It
|
||||||
|
upgrades in place and rewrites the gateway service to point at the new install.
|
||||||
|
|
||||||
|
Switch **to git install**:
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://clawd.bot/install.sh | bash -s -- --install-method git --no-onboard
|
||||||
|
```
|
||||||
|
|
||||||
|
Switch **to npm global**:
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://clawd.bot/install.sh | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- The git flow only rebases if the repo is clean. Commit or stash changes first.
|
||||||
|
- After switching, run:
|
||||||
|
```bash
|
||||||
|
clawdbot doctor
|
||||||
|
clawdbot daemon restart
|
||||||
|
```
|
||||||
|
|
||||||
|
### Telegram block streaming isn’t splitting text between tool calls. Why?
|
||||||
|
|
||||||
|
Block streaming only sends **completed text blocks**. Common reasons you see a single message:
|
||||||
|
- `agents.defaults.blockStreamingDefault` is still `"off"`.
|
||||||
|
- `channels.telegram.blockStreaming` is set to `false`.
|
||||||
|
- `channels.telegram.streamMode` is `partial` or `block` **and draft streaming is active**
|
||||||
|
(private chat + topics). Draft streaming disables block streaming in that case.
|
||||||
|
- Your `minChars` / coalesce settings are too high, so chunks get merged.
|
||||||
|
- The model emits one large text block (no mid‑reply flush points).
|
||||||
|
|
||||||
|
Fix checklist:
|
||||||
|
1) Put block streaming settings under `agents.defaults`, not the root.
|
||||||
|
2) Set `channels.telegram.streamMode: "off"` if you want real multi‑message block replies.
|
||||||
|
3) Use smaller chunk/coalesce thresholds while debugging.
|
||||||
|
|
||||||
|
See [Streaming](/concepts/streaming).
|
||||||
|
|
||||||
|
### Discord doesn’t reply in my server even with `requireMention: false`. Why?
|
||||||
|
|
||||||
|
`requireMention` only controls mention‑gating **after** the channel passes allowlists.
|
||||||
|
By default `channels.discord.groupPolicy` is **allowlist**, so guilds must be explicitly enabled.
|
||||||
|
If you set `channels.discord.guilds.<guildId>.channels`, only the listed channels are allowed; omit it to allow all channels in the guild.
|
||||||
|
|
||||||
|
Fix checklist:
|
||||||
|
1) Set `channels.discord.groupPolicy: "open"` **or** add a guild allowlist entry (and optionally a channel allowlist).
|
||||||
|
2) Use **numeric channel IDs** in `channels.discord.guilds.<guildId>.channels`.
|
||||||
|
3) Put `requireMention: false` **under** `channels.discord.guilds` (global or per‑channel).
|
||||||
|
Top‑level `channels.discord.requireMention` is not a supported key.
|
||||||
|
4) Ensure the bot has **Message Content Intent** and channel permissions.
|
||||||
|
5) Run `clawdbot channels status --probe` for audit hints.
|
||||||
|
|
||||||
|
Docs: [Discord](/channels/discord), [Channels troubleshooting](/channels/troubleshooting).
|
||||||
|
|
||||||
|
### Cloud Code Assist API error: invalid tool schema (400). What now?
|
||||||
|
|
||||||
|
This is almost always a **tool schema compatibility** issue. The Cloud Code Assist
|
||||||
|
endpoint accepts a strict subset of JSON Schema. Clawdbot scrubs/normalizes tool
|
||||||
|
schemas in current `main`, but the fix is not in the last release yet (as of
|
||||||
|
January 13, 2026).
|
||||||
|
|
||||||
|
Fix checklist:
|
||||||
|
1) **Update Clawdbot**:
|
||||||
|
- If you can run from source, pull `main` and restart the gateway.
|
||||||
|
- Otherwise, wait for the next release that includes the schema scrubber.
|
||||||
|
2) Avoid unsupported keywords like `anyOf/oneOf/allOf`, `patternProperties`,
|
||||||
|
`additionalProperties`, `minLength`, `maxLength`, `format`, etc.
|
||||||
|
3) If you define custom tools, keep the top‑level schema as `type: "object"` with
|
||||||
|
`properties` and simple enums.
|
||||||
|
|
||||||
|
See [Tools](/tools) and [TypeBox schemas](/concepts/typebox).
|
||||||
|
|
||||||
## macOS Specific Issues
|
## macOS Specific Issues
|
||||||
|
|
||||||
### App Crashes when Granting Permissions (Speech/Mic)
|
### App Crashes when Granting Permissions (Speech/Mic)
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ services:
|
|||||||
[
|
[
|
||||||
"node",
|
"node",
|
||||||
"dist/index.js",
|
"dist/index.js",
|
||||||
"gateway-daemon",
|
"gateway",
|
||||||
"--bind",
|
"--bind",
|
||||||
"${CLAWDBOT_GATEWAY_BIND}",
|
"${CLAWDBOT_GATEWAY_BIND}",
|
||||||
"--port",
|
"--port",
|
||||||
|
|||||||
@@ -160,11 +160,15 @@ clawdbot plugins install <path> # add a local file/dir to plugins.l
|
|||||||
clawdbot plugins install ./extensions/voice-call # relative path ok
|
clawdbot plugins install ./extensions/voice-call # relative path ok
|
||||||
clawdbot plugins install ./plugin.tgz # install from a local tarball
|
clawdbot plugins install ./plugin.tgz # install from a local tarball
|
||||||
clawdbot plugins install @clawdbot/voice-call # install from npm
|
clawdbot plugins install @clawdbot/voice-call # install from npm
|
||||||
|
clawdbot plugins update <id>
|
||||||
|
clawdbot plugins update --all
|
||||||
clawdbot plugins enable <id>
|
clawdbot plugins enable <id>
|
||||||
clawdbot plugins disable <id>
|
clawdbot plugins disable <id>
|
||||||
clawdbot plugins doctor
|
clawdbot plugins doctor
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`plugins update` only works for npm installs tracked under `plugins.installs`.
|
||||||
|
|
||||||
Plugins may also register their own top‑level commands (example: `clawdbot voicecall`).
|
Plugins may also register their own top‑level commands (example: `clawdbot voicecall`).
|
||||||
|
|
||||||
## Plugin API (overview)
|
## Plugin API (overview)
|
||||||
|
|||||||
+78
-146
@@ -70,11 +70,14 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
|||||||
- [I set `COPILOT_GITHUB_TOKEN`, but models status shows “Shell env: off.” Why?](#i-set-copilot_github_token-but-models-status-shows-shell-env-off-why)
|
- [I set `COPILOT_GITHUB_TOKEN`, but models status shows “Shell env: off.” Why?](#i-set-copilot_github_token-but-models-status-shows-shell-env-off-why)
|
||||||
- [Sessions & multiple chats](#sessions-multiple-chats)
|
- [Sessions & multiple chats](#sessions-multiple-chats)
|
||||||
- [How do I start a fresh conversation?](#how-do-i-start-a-fresh-conversation)
|
- [How do I start a fresh conversation?](#how-do-i-start-a-fresh-conversation)
|
||||||
- [How do I completely reset Clawdbot (but keep it installed)?](#how-do-i-completely-reset-clawdbot-but-keep-it-installed)
|
- [Do sessions reset automatically if I never send `/new`?](#do-sessions-reset-automatically-if-i-never-send-new)
|
||||||
|
- [How do I completely reset Clawdbot but keep it installed?](#how-do-i-completely-reset-clawdbot-but-keep-it-installed)
|
||||||
- [I’m getting “context too large” errors — how do I reset or compact?](#im-getting-context-too-large-errors-how-do-i-reset-or-compact)
|
- [I’m getting “context too large” errors — how do I reset or compact?](#im-getting-context-too-large-errors-how-do-i-reset-or-compact)
|
||||||
|
- [Why am I getting heartbeat messages every 30 minutes?](#why-am-i-getting-heartbeat-messages-every-30-minutes)
|
||||||
- [Do I need to add a “bot account” to a WhatsApp group?](#do-i-need-to-add-a-bot-account-to-a-whatsapp-group)
|
- [Do I need to add a “bot account” to a WhatsApp group?](#do-i-need-to-add-a-bot-account-to-a-whatsapp-group)
|
||||||
- [Why doesn’t Clawdbot reply in a group?](#why-doesnt-clawdbot-reply-in-a-group)
|
- [Why doesn’t Clawdbot reply in a group?](#why-doesnt-clawdbot-reply-in-a-group)
|
||||||
- [Do groups/threads share context with DMs?](#do-groupsthreads-share-context-with-dms)
|
- [Do groups/threads share context with DMs?](#do-groupsthreads-share-context-with-dms)
|
||||||
|
- [How many workspaces and agents can I create?](#how-many-workspaces-and-agents-can-i-create)
|
||||||
- [Models: defaults, selection, aliases, switching](#models-defaults-selection-aliases-switching)
|
- [Models: defaults, selection, aliases, switching](#models-defaults-selection-aliases-switching)
|
||||||
- [What is the “default model”?](#what-is-the-default-model)
|
- [What is the “default model”?](#what-is-the-default-model)
|
||||||
- [How do I switch models on the fly (without restarting)?](#how-do-i-switch-models-on-the-fly-without-restarting)
|
- [How do I switch models on the fly (without restarting)?](#how-do-i-switch-models-on-the-fly-without-restarting)
|
||||||
@@ -107,25 +110,17 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
|||||||
- [Logging and debugging](#logging-and-debugging)
|
- [Logging and debugging](#logging-and-debugging)
|
||||||
- [Where are logs?](#where-are-logs)
|
- [Where are logs?](#where-are-logs)
|
||||||
- [How do I start/stop/restart the Gateway daemon?](#how-do-i-startstoprestart-the-gateway-daemon)
|
- [How do I start/stop/restart the Gateway daemon?](#how-do-i-startstoprestart-the-gateway-daemon)
|
||||||
|
- [ELI5: `clawdbot daemon restart` vs `clawdbot gateway`](#eli5-clawdbot-daemon-restart-vs-clawdbot-gateway)
|
||||||
- [What’s the fastest way to get more details when something fails?](#whats-the-fastest-way-to-get-more-details-when-something-fails)
|
- [What’s the fastest way to get more details when something fails?](#whats-the-fastest-way-to-get-more-details-when-something-fails)
|
||||||
- [Media & attachments](#media-attachments)
|
- [Media & attachments](#media-attachments)
|
||||||
- [My skill generated an image/PDF, but nothing was sent](#my-skill-generated-an-imagepdf-but-nothing-was-sent)
|
- [My skill generated an image/PDF, but nothing was sent](#my-skill-generated-an-imagepdf-but-nothing-was-sent)
|
||||||
- [Security and access control](#security-and-access-control)
|
- [Security and access control](#security-and-access-control)
|
||||||
- [Is it safe to expose Clawdbot to inbound DMs?](#is-it-safe-to-expose-clawdbot-to-inbound-dms)
|
- [Is it safe to expose Clawdbot to inbound DMs?](#is-it-safe-to-expose-clawdbot-to-inbound-dms)
|
||||||
|
- [I ran `/start` in Telegram but didn’t get a pairing code](#i-ran-start-in-telegram-but-didnt-get-a-pairing-code)
|
||||||
- [WhatsApp: will it message my contacts? How does pairing work?](#whatsapp-will-it-message-my-contacts-how-does-pairing-work)
|
- [WhatsApp: will it message my contacts? How does pairing work?](#whatsapp-will-it-message-my-contacts-how-does-pairing-work)
|
||||||
- [Chat commands, aborting tasks, and “it won’t stop”](#chat-commands-aborting-tasks-and-it-wont-stop)
|
- [Chat commands, aborting tasks, and “it won’t stop”](#chat-commands-aborting-tasks-and-it-wont-stop)
|
||||||
- [How do I stop/cancel a running task?](#how-do-i-stopcancel-a-running-task)
|
- [How do I stop/cancel a running task?](#how-do-i-stopcancel-a-running-task)
|
||||||
- [Why does it feel like the bot “ignores” rapid‑fire messages?](#why-does-it-feel-like-the-bot-ignores-rapidfire-messages)
|
- [Why does it feel like the bot “ignores” rapid‑fire messages?](#why-does-it-feel-like-the-bot-ignores-rapidfire-messages)
|
||||||
- [Common troubleshooting](#common-troubleshooting)
|
|
||||||
- [“All models failed” — what should I check first?](#all-models-failed-what-should-i-check-first)
|
|
||||||
- [I’m running on my personal WhatsApp number — why is self-chat weird?](#im-running-on-my-personal-whatsapp-number-why-is-self-chat-weird)
|
|
||||||
- [WhatsApp logged me out. How do I re‑auth?](#whatsapp-logged-me-out-how-do-i-reauth)
|
|
||||||
- [Build errors on `main` — what’s the standard fix path?](#build-errors-on-main-whats-the-standard-fix-path)
|
|
||||||
- [npm install fails (allow-build-scripts / missing tar or yargs). What now?](#npm-install-fails-allow-build-scripts--missing-tar-or-yargs-what-now)
|
|
||||||
- [How do I switch between git installs and npm installs?](#how-do-i-switch-between-git-installs-and-npm-installs)
|
|
||||||
- [Telegram block streaming isn’t splitting text between tool calls. Why?](#telegram-block-streaming-isnt-splitting-text-between-tool-calls-why)
|
|
||||||
- [Discord doesn’t reply in my server even with `requireMention: false`. Why?](#discord-doesnt-reply-in-my-server-even-with-requiremention-false-why)
|
|
||||||
- [Cloud Code Assist API error: invalid tool schema (400). What now?](#cloud-code-assist-api-error-invalid-tool-schema-400-what-now)
|
|
||||||
|
|
||||||
## First 60 seconds if something's broken
|
## First 60 seconds if something's broken
|
||||||
|
|
||||||
@@ -858,7 +853,21 @@ See [/concepts/model-providers](/concepts/model-providers) and [/environment](/e
|
|||||||
|
|
||||||
Send `/new` or `/reset` as a standalone message. See [Session management](/concepts/session).
|
Send `/new` or `/reset` as a standalone message. See [Session management](/concepts/session).
|
||||||
|
|
||||||
### How do I completely reset Clawdbot (but keep it installed)?
|
### Do sessions reset automatically if I never send `/new`?
|
||||||
|
|
||||||
|
Yes. Sessions expire after `session.idleMinutes` (default **60**). The **next**
|
||||||
|
message starts a fresh session id for that chat key. This does not delete
|
||||||
|
transcripts — it just starts a new session.
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
session: {
|
||||||
|
idleMinutes: 240
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### How do I completely reset Clawdbot but keep it installed?
|
||||||
|
|
||||||
Use the reset command:
|
Use the reset command:
|
||||||
|
|
||||||
@@ -905,6 +914,24 @@ If it keeps happening:
|
|||||||
|
|
||||||
Docs: [Compaction](/concepts/compaction), [Session pruning](/concepts/session-pruning), [Session management](/concepts/session).
|
Docs: [Compaction](/concepts/compaction), [Session pruning](/concepts/session-pruning), [Session management](/concepts/session).
|
||||||
|
|
||||||
|
### Why am I getting heartbeat messages every 30 minutes?
|
||||||
|
|
||||||
|
Heartbeats run every **30m** by default. Tune or disable them:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
heartbeat: {
|
||||||
|
every: "2h" // or "0m" to disable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Per-agent overrides use `agents.list[].heartbeat`. Docs: [Heartbeat](/gateway/heartbeat).
|
||||||
|
|
||||||
### Do I need to add a “bot account” to a WhatsApp group?
|
### Do I need to add a “bot account” to a WhatsApp group?
|
||||||
|
|
||||||
No. Clawdbot runs on **your own account**, so if you’re in the group, Clawdbot can see it.
|
No. Clawdbot runs on **your own account**, so if you’re in the group, Clawdbot can see it.
|
||||||
@@ -935,6 +962,19 @@ See [Groups](/concepts/groups) and [Group messages](/concepts/group-messages).
|
|||||||
|
|
||||||
Direct chats collapse to the main session by default. Groups/channels have their own session keys, and Telegram topics / Discord threads are separate sessions. See [Groups](/concepts/groups) and [Group messages](/concepts/group-messages).
|
Direct chats collapse to the main session by default. Groups/channels have their own session keys, and Telegram topics / Discord threads are separate sessions. See [Groups](/concepts/groups) and [Group messages](/concepts/group-messages).
|
||||||
|
|
||||||
|
### How many workspaces and agents can I create?
|
||||||
|
|
||||||
|
No hard limits. Dozens (even hundreds) are fine, but watch for:
|
||||||
|
|
||||||
|
- **Disk growth:** sessions + transcripts live under `~/.clawdbot/agents/<agentId>/sessions/`.
|
||||||
|
- **Token cost:** more agents means more concurrent model usage.
|
||||||
|
- **Ops overhead:** per-agent auth profiles, workspaces, and channel routing.
|
||||||
|
|
||||||
|
Tips:
|
||||||
|
- Keep one **active** workspace per agent (`agents.defaults.workspace`).
|
||||||
|
- Prune old sessions (delete JSONL or store entries) if disk grows.
|
||||||
|
- Use `clawdbot doctor` to spot stray workspaces and profile mismatches.
|
||||||
|
|
||||||
## Models: defaults, selection, aliases, switching
|
## Models: defaults, selection, aliases, switching
|
||||||
|
|
||||||
### What is the “default model”?
|
### What is the “default model”?
|
||||||
@@ -1407,6 +1447,14 @@ clawdbot daemon restart
|
|||||||
|
|
||||||
If you run the gateway manually, `clawdbot gateway --force` can reclaim the port. See [Gateway](/gateway).
|
If you run the gateway manually, `clawdbot gateway --force` can reclaim the port. See [Gateway](/gateway).
|
||||||
|
|
||||||
|
### ELI5: `clawdbot daemon restart` vs `clawdbot gateway`
|
||||||
|
|
||||||
|
- `clawdbot daemon restart`: restarts the **background service** (launchd/systemd).
|
||||||
|
- `clawdbot gateway`: runs the gateway **in the foreground** for this terminal session.
|
||||||
|
|
||||||
|
If you installed the daemon, use the daemon commands. Use `clawdbot gateway` when
|
||||||
|
you want a one-off, foreground run.
|
||||||
|
|
||||||
### What’s the fastest way to get more details when something fails?
|
### What’s the fastest way to get more details when something fails?
|
||||||
|
|
||||||
Start the Gateway with `--verbose` to get more console detail. Then inspect the log file for channel auth, model routing, and RPC errors.
|
Start the Gateway with `--verbose` to get more console detail. Then inspect the log file for channel auth, model routing, and RPC errors.
|
||||||
@@ -1423,7 +1471,11 @@ CLI sending:
|
|||||||
clawdbot message send --to +15555550123 --message "Here you go" --media /path/to/file.png
|
clawdbot message send --to +15555550123 --message "Here you go" --media /path/to/file.png
|
||||||
```
|
```
|
||||||
|
|
||||||
Note: images are resized/recompressed (max side 2048px) to hit size limits. See [Images](/nodes/images).
|
Also check:
|
||||||
|
- The target channel supports outbound media and isn’t blocked by allowlists.
|
||||||
|
- The file is within the provider’s size limits (images are resized to max 2048px).
|
||||||
|
|
||||||
|
See [Images](/nodes/images).
|
||||||
|
|
||||||
## Security and access control
|
## Security and access control
|
||||||
|
|
||||||
@@ -1439,6 +1491,19 @@ Treat inbound DMs as untrusted input. Defaults are designed to reduce risk:
|
|||||||
|
|
||||||
Run `clawdbot doctor` to surface risky DM policies.
|
Run `clawdbot doctor` to surface risky DM policies.
|
||||||
|
|
||||||
|
### I ran `/start` in Telegram but didn’t get a pairing code
|
||||||
|
|
||||||
|
Pairing codes are sent **only** when an unknown sender messages the bot and
|
||||||
|
`dmPolicy: "pairing"` is enabled. `/start` by itself doesn’t generate a code.
|
||||||
|
|
||||||
|
Check pending requests:
|
||||||
|
```bash
|
||||||
|
clawdbot pairing list telegram
|
||||||
|
```
|
||||||
|
|
||||||
|
If you want immediate access, allowlist your sender id or set `dmPolicy: "open"`
|
||||||
|
for that account.
|
||||||
|
|
||||||
### WhatsApp: will it message my contacts? How does pairing work?
|
### WhatsApp: will it message my contacts? How does pairing work?
|
||||||
|
|
||||||
No. Default WhatsApp DM policy is **pairing**. Unknown senders only get a pairing code and their message is **not processed**. Clawdbot only replies to chats it receives or to explicit sends you trigger.
|
No. Default WhatsApp DM policy is **pairing**. Unknown senders only get a pairing code and their message is **not processed**. Clawdbot only replies to chats it receives or to explicit sends you trigger.
|
||||||
@@ -1496,139 +1561,6 @@ Queue mode controls how new messages interact with an in‑flight run. Use `/que
|
|||||||
|
|
||||||
You can add options like `debounce:2s cap:25 drop:summarize` for followup modes.
|
You can add options like `debounce:2s cap:25 drop:summarize` for followup modes.
|
||||||
|
|
||||||
## Common troubleshooting
|
|
||||||
|
|
||||||
### “All models failed” — what should I check first?
|
|
||||||
|
|
||||||
- **Credentials** present for the provider(s) being tried (auth profiles + env vars).
|
|
||||||
- **Model routing**: confirm `agents.defaults.model.primary` and fallbacks are models you can access.
|
|
||||||
- **Gateway logs** in `/tmp/clawdbot/…` for the exact provider error.
|
|
||||||
- **`/model status`** to see current configured models + shorthands.
|
|
||||||
|
|
||||||
### I’m running on my personal WhatsApp number — why is self-chat weird?
|
|
||||||
|
|
||||||
Enable self-chat mode and allowlist your own number:
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
channels: {
|
|
||||||
whatsapp: {
|
|
||||||
selfChatMode: true,
|
|
||||||
dmPolicy: "allowlist",
|
|
||||||
allowFrom: ["+15555550123"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
See [WhatsApp setup](/channels/whatsapp).
|
|
||||||
|
|
||||||
### WhatsApp logged me out. How do I re‑auth?
|
|
||||||
|
|
||||||
Run the login command again and scan the QR code:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot channels login
|
|
||||||
```
|
|
||||||
|
|
||||||
### Build errors on `main` — what’s the standard fix path?
|
|
||||||
|
|
||||||
1) `git pull origin main && pnpm install`
|
|
||||||
2) `pnpm clawdbot doctor`
|
|
||||||
3) Check GitHub issues or Discord
|
|
||||||
4) Temporary workaround: check out an older commit
|
|
||||||
|
|
||||||
### npm install fails (allow-build-scripts / missing tar or yargs). What now?
|
|
||||||
|
|
||||||
If you’re running from source, use the repo’s package manager: **pnpm** (preferred).
|
|
||||||
The repo declares `packageManager: "pnpm@…"`, and pnpm patches are tracked in `pnpm.patchedDependencies`.
|
|
||||||
|
|
||||||
Typical recovery:
|
|
||||||
```bash
|
|
||||||
git status # ensure you’re in the repo root
|
|
||||||
pnpm install
|
|
||||||
pnpm build
|
|
||||||
pnpm clawdbot doctor
|
|
||||||
clawdbot daemon restart
|
|
||||||
```
|
|
||||||
|
|
||||||
Why: pnpm is the configured package manager for this repo, and the dependency
|
|
||||||
patching workflow relies on it.
|
|
||||||
|
|
||||||
### How do I switch between git installs and npm installs?
|
|
||||||
|
|
||||||
Use the **website installer** and select the install method with a flag. It
|
|
||||||
upgrades in place and rewrites the gateway service to point at the new install.
|
|
||||||
|
|
||||||
Switch **to git install**:
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://clawd.bot/install.sh | bash -s -- --install-method git --no-onboard
|
|
||||||
```
|
|
||||||
|
|
||||||
Switch **to npm global**:
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://clawd.bot/install.sh | bash
|
|
||||||
```
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- The git flow only rebases if the repo is clean. Commit or stash changes first.
|
|
||||||
- After switching, run:
|
|
||||||
```bash
|
|
||||||
clawdbot doctor
|
|
||||||
clawdbot daemon restart
|
|
||||||
```
|
|
||||||
|
|
||||||
### Telegram block streaming isn’t splitting text between tool calls. Why?
|
|
||||||
|
|
||||||
Block streaming only sends **completed text blocks**. Common reasons you see a single message:
|
|
||||||
- `agents.defaults.blockStreamingDefault` is still `"off"`.
|
|
||||||
- `channels.telegram.blockStreaming` is set to `false`.
|
|
||||||
- `channels.telegram.streamMode` is `partial` or `block` **and draft streaming is active**
|
|
||||||
(private chat + topics). Draft streaming disables block streaming in that case.
|
|
||||||
- Your `minChars` / coalesce settings are too high, so chunks get merged.
|
|
||||||
- The model emits one large text block (no mid‑reply flush points).
|
|
||||||
|
|
||||||
Fix checklist:
|
|
||||||
1) Put block streaming settings under `agents.defaults`, not the root.
|
|
||||||
2) Set `channels.telegram.streamMode: "off"` if you want real multi‑message block replies.
|
|
||||||
3) Use smaller chunk/coalesce thresholds while debugging.
|
|
||||||
|
|
||||||
See [Streaming](/concepts/streaming).
|
|
||||||
|
|
||||||
### Discord doesn’t reply in my server even with `requireMention: false`. Why?
|
|
||||||
|
|
||||||
`requireMention` only controls mention‑gating **after** the channel passes allowlists.
|
|
||||||
By default `channels.discord.groupPolicy` is **allowlist**, so guilds must be explicitly enabled.
|
|
||||||
If you set `channels.discord.guilds.<guildId>.channels`, only the listed channels are allowed; omit it to allow all channels in the guild.
|
|
||||||
|
|
||||||
Fix checklist:
|
|
||||||
1) Set `channels.discord.groupPolicy: "open"` **or** add a guild allowlist entry (and optionally a channel allowlist).
|
|
||||||
2) Use **numeric channel IDs** in `channels.discord.guilds.<guildId>.channels`.
|
|
||||||
3) Put `requireMention: false` **under** `channels.discord.guilds` (global or per‑channel).
|
|
||||||
Top‑level `channels.discord.requireMention` is not a supported key.
|
|
||||||
4) Ensure the bot has **Message Content Intent** and channel permissions.
|
|
||||||
5) Run `clawdbot channels status --probe` for audit hints.
|
|
||||||
|
|
||||||
Docs: [Discord](/channels/discord), [Channels troubleshooting](/channels/troubleshooting).
|
|
||||||
|
|
||||||
### Cloud Code Assist API error: invalid tool schema (400). What now?
|
|
||||||
|
|
||||||
This is almost always a **tool schema compatibility** issue. The Cloud Code Assist
|
|
||||||
endpoint accepts a strict subset of JSON Schema. Clawdbot scrubs/normalizes tool
|
|
||||||
schemas in current `main`, but the fix is not in the last release yet (as of
|
|
||||||
January 13, 2026).
|
|
||||||
|
|
||||||
Fix checklist:
|
|
||||||
1) **Update Clawdbot**:
|
|
||||||
- If you can run from source, pull `main` and restart the gateway.
|
|
||||||
- Otherwise, wait for the next release that includes the schema scrubber.
|
|
||||||
2) Avoid unsupported keywords like `anyOf/oneOf/allOf`, `patternProperties`,
|
|
||||||
`additionalProperties`, `minLength`, `maxLength`, `format`, etc.
|
|
||||||
3) If you define custom tools, keep the top‑level schema as `type: "object"` with
|
|
||||||
`properties` and simple enums.
|
|
||||||
|
|
||||||
See [Tools](/tools) and [TypeBox schemas](/concepts/typebox).
|
|
||||||
|
|
||||||
## Answer the exact question from the screenshot/chat log
|
## Answer the exact question from the screenshot/chat log
|
||||||
|
|
||||||
**Q: “What’s the default model for Anthropic with an API key?”**
|
**Q: “What’s the default model for Anthropic with an API key?”**
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
|
|||||||
|
|
||||||
1) **Existing config detection**
|
1) **Existing config detection**
|
||||||
- If `~/.clawdbot/clawdbot.json` exists, choose **Keep / Modify / Reset**.
|
- If `~/.clawdbot/clawdbot.json` exists, choose **Keep / Modify / Reset**.
|
||||||
|
- Re-running the wizard does **not** wipe anything unless you explicitly choose **Reset**
|
||||||
|
(or pass `--reset`).
|
||||||
- If the config is invalid or contains legacy keys, the wizard stops and asks
|
- If the config is invalid or contains legacy keys, the wizard stops and asks
|
||||||
you to run `clawdbot doctor` before continuing.
|
you to run `clawdbot doctor` before continuing.
|
||||||
- Reset uses `trash` (never `rm`) and offers scopes:
|
- Reset uses `trash` (never `rm`) and offers scopes:
|
||||||
|
|||||||
@@ -112,3 +112,18 @@ curl -s http://127.0.0.1:18791/tabs
|
|||||||
| `browser.noSandbox` | Add `--no-sandbox` flag (needed for some Linux setups) | `false` |
|
| `browser.noSandbox` | Add `--no-sandbox` flag (needed for some Linux setups) | `false` |
|
||||||
| `browser.attachOnly` | Don't launch browser, only attach to existing | `false` |
|
| `browser.attachOnly` | Don't launch browser, only attach to existing | `false` |
|
||||||
| `browser.cdpPort` | Chrome DevTools Protocol port | `18800` |
|
| `browser.cdpPort` | Chrome DevTools Protocol port | `18800` |
|
||||||
|
|
||||||
|
### Problem: "Chrome extension relay is running, but no tab is connected"
|
||||||
|
|
||||||
|
You’re using the `chrome` profile (extension relay). It expects the Clawdbot
|
||||||
|
browser extension to be attached to a live tab.
|
||||||
|
|
||||||
|
Fix options:
|
||||||
|
1. **Use the managed browser:** `clawdbot browser start --browser-profile clawd`
|
||||||
|
(or set `browser.defaultProfile: "clawd"`).
|
||||||
|
2. **Use the extension relay:** install the extension, open a tab, and click the
|
||||||
|
Clawdbot extension icon to attach it.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- The `chrome` profile uses your **system default Chromium browser** when possible.
|
||||||
|
- Local `clawd` profiles auto-assign `cdpPort`/`cdpUrl`; only set those for remote CDP.
|
||||||
+27
-3
@@ -14,8 +14,10 @@ control server.
|
|||||||
|
|
||||||
Beginner view:
|
Beginner view:
|
||||||
- Think of it as a **separate, agent-only browser**.
|
- Think of it as a **separate, agent-only browser**.
|
||||||
- It does **not** touch your personal browser profile.
|
- The `clawd` profile does **not** touch your personal browser profile.
|
||||||
- The agent can **open tabs, read pages, click, and type** in a safe lane.
|
- The agent can **open tabs, read pages, click, and type** in a safe lane.
|
||||||
|
- The default `chrome` profile uses the **system default Chromium browser** via the
|
||||||
|
extension relay; switch to `clawd` for the isolated managed browser.
|
||||||
|
|
||||||
## What you get
|
## What you get
|
||||||
|
|
||||||
@@ -39,6 +41,14 @@ clawdbot browser --browser-profile clawd snapshot
|
|||||||
If you get “Browser disabled”, enable it in config (see below) and restart the
|
If you get “Browser disabled”, enable it in config (see below) and restart the
|
||||||
Gateway.
|
Gateway.
|
||||||
|
|
||||||
|
## Profiles: `clawd` vs `chrome`
|
||||||
|
|
||||||
|
- `clawd`: managed, isolated browser (no extension required).
|
||||||
|
- `chrome`: extension relay to your **system browser** (requires the Clawdbot
|
||||||
|
extension to be attached to a tab).
|
||||||
|
|
||||||
|
Set `browser.defaultProfile: "clawd"` if you want managed mode by default.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Browser settings live in `~/.clawdbot/clawdbot.json`.
|
Browser settings live in `~/.clawdbot/clawdbot.json`.
|
||||||
@@ -71,11 +81,21 @@ Notes:
|
|||||||
- `cdpUrl` defaults to `controlUrl + 1` when unset.
|
- `cdpUrl` defaults to `controlUrl + 1` when unset.
|
||||||
- `attachOnly: true` means “never launch a local browser; only attach if it is already running.”
|
- `attachOnly: true` means “never launch a local browser; only attach if it is already running.”
|
||||||
- `color` + per-profile `color` tint the browser UI so you can see which profile is active.
|
- `color` + per-profile `color` tint the browser UI so you can see which profile is active.
|
||||||
- Auto-detect order: default browser if Chromium-based; otherwise Chrome → Brave → Edge → Chromium → Chrome Canary.
|
- Default profile is `chrome` (extension relay). Use `defaultProfile: "clawd"` for the managed browser.
|
||||||
|
- Auto-detect order: system default browser if Chromium-based; otherwise Chrome → Brave → Edge → Chromium → Chrome Canary.
|
||||||
|
- Local `clawd` profiles auto-assign `cdpPort`/`cdpUrl` — set those only for remote CDP.
|
||||||
|
|
||||||
## Use Brave (or another Chromium-based browser)
|
## Use Brave (or another Chromium-based browser)
|
||||||
|
|
||||||
Set `browser.executablePath` to override auto-detection:
|
If your **system default** browser is Chromium-based (Chrome/Brave/Edge/etc),
|
||||||
|
Clawdbot uses it automatically. Set `browser.executablePath` to override
|
||||||
|
auto-detection:
|
||||||
|
|
||||||
|
CLI example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot config set browser.executablePath "/usr/bin/google-chrome"
|
||||||
|
```
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
// macOS
|
// macOS
|
||||||
@@ -339,6 +359,10 @@ Playwright. If Playwright isn’t installed, those endpoints return a clear 501
|
|||||||
error. ARIA snapshots and basic screenshots still work for clawd-managed Chrome.
|
error. ARIA snapshots and basic screenshots still work for clawd-managed Chrome.
|
||||||
For the Chrome extension relay driver, ARIA snapshots and screenshots require Playwright.
|
For the Chrome extension relay driver, ARIA snapshots and screenshots require Playwright.
|
||||||
|
|
||||||
|
If you see `Playwright is not available in this gateway build`, install the full
|
||||||
|
Playwright package (not `playwright-core`) and restart the gateway, or reinstall
|
||||||
|
Clawdbot with browser support.
|
||||||
|
|
||||||
## How it works (internal)
|
## How it works (internal)
|
||||||
|
|
||||||
High-level flow:
|
High-level flow:
|
||||||
|
|||||||
+25
-7
@@ -206,6 +206,31 @@ if [[ "$NO_SIGN" -ne 1 && -f "${LAUNCHAGENT_DISABLE_MARKER}" ]]; then
|
|||||||
run_step "clear launchagent disable marker" /bin/rm -f "${LAUNCHAGENT_DISABLE_MARKER}"
|
run_step "clear launchagent disable marker" /bin/rm -f "${LAUNCHAGENT_DISABLE_MARKER}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# When unsigned, ensure the gateway LaunchAgent targets the repo CLI (before the app launches).
|
||||||
|
# This reduces noisy "could not connect" errors during app startup.
|
||||||
|
if [ "$NO_SIGN" -eq 1 ]; then
|
||||||
|
run_step "install gateway launch agent (unsigned)" bash -lc "cd '${ROOT_DIR}' && node dist/entry.js daemon install --force --runtime node"
|
||||||
|
run_step "restart gateway daemon (unsigned)" bash -lc "cd '${ROOT_DIR}' && node dist/entry.js daemon restart"
|
||||||
|
if [[ "${GATEWAY_WAIT_SECONDS}" -gt 0 ]]; then
|
||||||
|
run_step "wait for gateway (unsigned)" sleep "${GATEWAY_WAIT_SECONDS}"
|
||||||
|
fi
|
||||||
|
GATEWAY_PORT="$(
|
||||||
|
node -e '
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(path.join(process.env.HOME, ".clawdbot", "clawdbot.json"), "utf8");
|
||||||
|
const cfg = JSON.parse(raw);
|
||||||
|
const port = cfg && cfg.gateway && typeof cfg.gateway.port === "number" ? cfg.gateway.port : 18789;
|
||||||
|
process.stdout.write(String(port));
|
||||||
|
} catch {
|
||||||
|
process.stdout.write("18789");
|
||||||
|
}
|
||||||
|
'
|
||||||
|
)"
|
||||||
|
run_step "verify gateway port ${GATEWAY_PORT} (unsigned)" bash -lc "lsof -iTCP:${GATEWAY_PORT} -sTCP:LISTEN | head -n 5 || true"
|
||||||
|
fi
|
||||||
|
|
||||||
# 4) Launch the installed app in the foreground so the menu bar extra appears.
|
# 4) Launch the installed app in the foreground so the menu bar extra appears.
|
||||||
# LaunchServices can inherit a huge environment from this shell (secrets, prompt vars, etc.).
|
# LaunchServices can inherit a huge environment from this shell (secrets, prompt vars, etc.).
|
||||||
# That can cause launchd spawn failures and is undesirable for a GUI app anyway.
|
# That can cause launchd spawn failures and is undesirable for a GUI app anyway.
|
||||||
@@ -226,13 +251,6 @@ else
|
|||||||
fail "App exited immediately. Check ${LOG_PATH} or Console.app (User Reports)."
|
fail "App exited immediately. Check ${LOG_PATH} or Console.app (User Reports)."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# When unsigned, ensure the gateway LaunchAgent targets the repo CLI (after the app launches).
|
|
||||||
if [ "$NO_SIGN" -eq 1 ]; then
|
if [ "$NO_SIGN" -eq 1 ]; then
|
||||||
run_step "install gateway launch agent (unsigned)" bash -lc "cd '${ROOT_DIR}' && node dist/entry.js daemon install --force --runtime node"
|
|
||||||
run_step "restart gateway daemon (unsigned)" bash -lc "cd '${ROOT_DIR}' && node dist/entry.js daemon restart"
|
|
||||||
if [[ "${GATEWAY_WAIT_SECONDS}" -gt 0 ]]; then
|
|
||||||
run_step "wait for gateway (unsigned)" sleep "${GATEWAY_WAIT_SECONDS}"
|
|
||||||
fi
|
|
||||||
run_step "verify gateway port 18789 (unsigned)" bash -lc "lsof -iTCP:18789 -sTCP:LISTEN | head -n 5 || true"
|
|
||||||
run_step "show gateway launch agent args (unsigned)" bash -lc "/usr/bin/plutil -p '${HOME}/Library/LaunchAgents/com.clawdbot.gateway.plist' | head -n 40 || true"
|
run_step "show gateway launch agent args (unsigned)" bash -lc "/usr/bin/plutil -p '${HOME}/Library/LaunchAgents/com.clawdbot.gateway.plist' | head -n 40 || true"
|
||||||
fi
|
fi
|
||||||
@@ -75,7 +75,9 @@ export function createClawdbotTools(options?: {
|
|||||||
}),
|
}),
|
||||||
createCanvasTool(),
|
createCanvasTool(),
|
||||||
createNodesTool(),
|
createNodesTool(),
|
||||||
createCronTool(),
|
createCronTool({
|
||||||
|
agentSessionKey: options?.agentSessionKey,
|
||||||
|
}),
|
||||||
createMessageTool({
|
createMessageTool({
|
||||||
agentAccountId: options?.agentAccountId,
|
agentAccountId: options?.agentAccountId,
|
||||||
config: options?.config,
|
config: options?.config,
|
||||||
|
|||||||
@@ -44,7 +44,11 @@ import {
|
|||||||
} from "../skills.js";
|
} from "../skills.js";
|
||||||
import { filterBootstrapFilesForSession, loadWorkspaceBootstrapFiles } from "../workspace.js";
|
import { filterBootstrapFilesForSession, loadWorkspaceBootstrapFiles } from "../workspace.js";
|
||||||
import { buildEmbeddedExtensionPaths } from "./extensions.js";
|
import { buildEmbeddedExtensionPaths } from "./extensions.js";
|
||||||
import { logToolSchemasForGoogle, sanitizeSessionHistory } from "./google.js";
|
import {
|
||||||
|
logToolSchemasForGoogle,
|
||||||
|
sanitizeSessionHistory,
|
||||||
|
sanitizeToolsForGoogle,
|
||||||
|
} from "./google.js";
|
||||||
import { getDmHistoryLimitFromSessionKey, limitHistoryTurns } from "./history.js";
|
import { getDmHistoryLimitFromSessionKey, limitHistoryTurns } from "./history.js";
|
||||||
import { resolveGlobalLane, resolveSessionLane } from "./lanes.js";
|
import { resolveGlobalLane, resolveSessionLane } from "./lanes.js";
|
||||||
import { log } from "./logger.js";
|
import { log } from "./logger.js";
|
||||||
@@ -183,7 +187,7 @@ export async function compactEmbeddedPiSession(params: {
|
|||||||
warn: (message) => log.warn(`${message} (sessionKey=${sessionLabel})`),
|
warn: (message) => log.warn(`${message} (sessionKey=${sessionLabel})`),
|
||||||
});
|
});
|
||||||
const runAbortController = new AbortController();
|
const runAbortController = new AbortController();
|
||||||
const tools = createClawdbotCodingTools({
|
const toolsRaw = createClawdbotCodingTools({
|
||||||
exec: {
|
exec: {
|
||||||
...resolveExecToolDefaults(params.config),
|
...resolveExecToolDefaults(params.config),
|
||||||
elevated: params.bashElevated,
|
elevated: params.bashElevated,
|
||||||
@@ -200,6 +204,7 @@ export async function compactEmbeddedPiSession(params: {
|
|||||||
modelId,
|
modelId,
|
||||||
modelAuthMode: resolveModelAuthMode(model.provider, params.config),
|
modelAuthMode: resolveModelAuthMode(model.provider, params.config),
|
||||||
});
|
});
|
||||||
|
const tools = sanitizeToolsForGoogle({ tools: toolsRaw, provider });
|
||||||
logToolSchemasForGoogle({ tools, provider });
|
logToolSchemasForGoogle({ tools, provider });
|
||||||
const machineName = await getMachineDisplayName();
|
const machineName = await getMachineDisplayName();
|
||||||
const runtimeChannel = normalizeMessageChannel(
|
const runtimeChannel = normalizeMessageChannel(
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||||
|
import { sanitizeToolsForGoogle } from "./google.js";
|
||||||
|
|
||||||
|
describe("sanitizeToolsForGoogle", () => {
|
||||||
|
it("strips unsupported schema keywords for Google providers", () => {
|
||||||
|
const tool = {
|
||||||
|
name: "test",
|
||||||
|
description: "test",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
foo: {
|
||||||
|
type: "string",
|
||||||
|
format: "uuid",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
execute: async () => ({ ok: true, content: [] }),
|
||||||
|
} as unknown as AgentTool;
|
||||||
|
|
||||||
|
const [sanitized] = sanitizeToolsForGoogle({
|
||||||
|
tools: [tool],
|
||||||
|
provider: "google-gemini-cli",
|
||||||
|
});
|
||||||
|
|
||||||
|
const params = sanitized.parameters as {
|
||||||
|
additionalProperties?: unknown;
|
||||||
|
properties?: Record<string, { format?: unknown }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(params.additionalProperties).toBeUndefined();
|
||||||
|
expect(params.properties?.foo?.format).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { AgentMessage, AgentTool } from "@mariozechner/pi-agent-core";
|
import type { AgentMessage, AgentTool } from "@mariozechner/pi-agent-core";
|
||||||
|
import type { TSchema } from "@sinclair/typebox";
|
||||||
import type { SessionManager } from "@mariozechner/pi-coding-agent";
|
import type { SessionManager } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
import { registerUnhandledRejectionHandler } from "../../infra/unhandled-rejections.js";
|
import { registerUnhandledRejectionHandler } from "../../infra/unhandled-rejections.js";
|
||||||
@@ -14,6 +15,7 @@ import { sanitizeToolUseResultPairing } from "../session-transcript-repair.js";
|
|||||||
import { log } from "./logger.js";
|
import { log } from "./logger.js";
|
||||||
import { describeUnknownError } from "./utils.js";
|
import { describeUnknownError } from "./utils.js";
|
||||||
import { isAntigravityClaude } from "../pi-embedded-helpers/google.js";
|
import { isAntigravityClaude } from "../pi-embedded-helpers/google.js";
|
||||||
|
import { cleanToolSchemaForGemini } from "../pi-tools.schema.js";
|
||||||
|
|
||||||
const GOOGLE_TURN_ORDERING_CUSTOM_TYPE = "google-turn-ordering-bootstrap";
|
const GOOGLE_TURN_ORDERING_CUSTOM_TYPE = "google-turn-ordering-bootstrap";
|
||||||
const GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS = new Set([
|
const GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS = new Set([
|
||||||
@@ -80,17 +82,36 @@ function findUnsupportedSchemaKeywords(schema: unknown, path: string): string[]
|
|||||||
return violations;
|
return violations;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function sanitizeToolsForGoogle<TSchemaType extends TSchema = TSchema, TResult = unknown>(
|
||||||
|
params: {
|
||||||
|
tools: AgentTool<TSchemaType, TResult>[];
|
||||||
|
provider: string;
|
||||||
|
},
|
||||||
|
): AgentTool<TSchemaType, TResult>[] {
|
||||||
|
if (params.provider !== "google-antigravity" && params.provider !== "google-gemini-cli") {
|
||||||
|
return params.tools;
|
||||||
|
}
|
||||||
|
return params.tools.map((tool) => {
|
||||||
|
if (!tool.parameters || typeof tool.parameters !== "object") return tool;
|
||||||
|
return {
|
||||||
|
...tool,
|
||||||
|
parameters: cleanToolSchemaForGemini(tool.parameters as Record<string, unknown>) as TSchemaType,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function logToolSchemasForGoogle(params: { tools: AgentTool[]; provider: string }) {
|
export function logToolSchemasForGoogle(params: { tools: AgentTool[]; provider: string }) {
|
||||||
if (params.provider !== "google-antigravity" && params.provider !== "google-gemini-cli") {
|
if (params.provider !== "google-antigravity" && params.provider !== "google-gemini-cli") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const toolNames = params.tools.map((tool, index) => `${index}:${tool.name}`);
|
const toolNames = params.tools.map((tool, index) => `${index}:${tool.name}`);
|
||||||
|
const tools = sanitizeToolsForGoogle(params);
|
||||||
log.info("google tool schema snapshot", {
|
log.info("google tool schema snapshot", {
|
||||||
provider: params.provider,
|
provider: params.provider,
|
||||||
toolCount: params.tools.length,
|
toolCount: tools.length,
|
||||||
tools: toolNames,
|
tools: toolNames,
|
||||||
});
|
});
|
||||||
for (const [index, tool] of params.tools.entries()) {
|
for (const [index, tool] of tools.entries()) {
|
||||||
const violations = findUnsupportedSchemaKeywords(tool.parameters, `${tool.name}.parameters`);
|
const violations = findUnsupportedSchemaKeywords(tool.parameters, `${tool.name}.parameters`);
|
||||||
if (violations.length > 0) {
|
if (violations.length > 0) {
|
||||||
log.warn("google tool schema has unsupported keywords", {
|
log.warn("google tool schema has unsupported keywords", {
|
||||||
|
|||||||
@@ -45,7 +45,11 @@ import { filterBootstrapFilesForSession, loadWorkspaceBootstrapFiles } from "../
|
|||||||
import { isAbortError } from "../abort.js";
|
import { isAbortError } from "../abort.js";
|
||||||
import { buildEmbeddedExtensionPaths } from "../extensions.js";
|
import { buildEmbeddedExtensionPaths } from "../extensions.js";
|
||||||
import { applyExtraParamsToAgent } from "../extra-params.js";
|
import { applyExtraParamsToAgent } from "../extra-params.js";
|
||||||
import { logToolSchemasForGoogle, sanitizeSessionHistory } from "../google.js";
|
import {
|
||||||
|
logToolSchemasForGoogle,
|
||||||
|
sanitizeSessionHistory,
|
||||||
|
sanitizeToolsForGoogle,
|
||||||
|
} from "../google.js";
|
||||||
import { getDmHistoryLimitFromSessionKey, limitHistoryTurns } from "../history.js";
|
import { getDmHistoryLimitFromSessionKey, limitHistoryTurns } from "../history.js";
|
||||||
import { log } from "../logger.js";
|
import { log } from "../logger.js";
|
||||||
import { buildModelAliasLines } from "../model.js";
|
import { buildModelAliasLines } from "../model.js";
|
||||||
@@ -127,7 +131,7 @@ export async function runEmbeddedAttempt(
|
|||||||
|
|
||||||
const agentDir = params.agentDir ?? resolveClawdbotAgentDir();
|
const agentDir = params.agentDir ?? resolveClawdbotAgentDir();
|
||||||
|
|
||||||
const tools = createClawdbotCodingTools({
|
const toolsRaw = createClawdbotCodingTools({
|
||||||
exec: {
|
exec: {
|
||||||
...resolveExecToolDefaults(params.config),
|
...resolveExecToolDefaults(params.config),
|
||||||
elevated: params.bashElevated,
|
elevated: params.bashElevated,
|
||||||
@@ -148,6 +152,7 @@ export async function runEmbeddedAttempt(
|
|||||||
replyToMode: params.replyToMode,
|
replyToMode: params.replyToMode,
|
||||||
hasRepliedRef: params.hasRepliedRef,
|
hasRepliedRef: params.hasRepliedRef,
|
||||||
});
|
});
|
||||||
|
const tools = sanitizeToolsForGoogle({ tools: toolsRaw, provider: params.provider });
|
||||||
logToolSchemasForGoogle({ tools, provider: params.provider });
|
logToolSchemasForGoogle({ tools, provider: params.provider });
|
||||||
|
|
||||||
const machineName = await getMachineDisplayName();
|
const machineName = await getMachineDisplayName();
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export function handleAgentStart(ctx: EmbeddedPiSubscribeContext) {
|
|||||||
startedAt: Date.now(),
|
startedAt: Date.now(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
ctx.params.onAgentEvent?.({
|
void ctx.params.onAgentEvent?.({
|
||||||
stream: "lifecycle",
|
stream: "lifecycle",
|
||||||
data: { phase: "start" },
|
data: { phase: "start" },
|
||||||
});
|
});
|
||||||
@@ -24,7 +24,7 @@ export function handleAutoCompactionStart(ctx: EmbeddedPiSubscribeContext) {
|
|||||||
ctx.state.compactionInFlight = true;
|
ctx.state.compactionInFlight = true;
|
||||||
ctx.ensureCompactionPromise();
|
ctx.ensureCompactionPromise();
|
||||||
ctx.log.debug(`embedded run compaction start: runId=${ctx.params.runId}`);
|
ctx.log.debug(`embedded run compaction start: runId=${ctx.params.runId}`);
|
||||||
ctx.params.onAgentEvent?.({
|
void ctx.params.onAgentEvent?.({
|
||||||
stream: "compaction",
|
stream: "compaction",
|
||||||
data: { phase: "start" },
|
data: { phase: "start" },
|
||||||
});
|
});
|
||||||
@@ -43,7 +43,7 @@ export function handleAutoCompactionEnd(
|
|||||||
} else {
|
} else {
|
||||||
ctx.maybeResolveCompactionWait();
|
ctx.maybeResolveCompactionWait();
|
||||||
}
|
}
|
||||||
ctx.params.onAgentEvent?.({
|
void ctx.params.onAgentEvent?.({
|
||||||
stream: "compaction",
|
stream: "compaction",
|
||||||
data: { phase: "end", willRetry },
|
data: { phase: "end", willRetry },
|
||||||
});
|
});
|
||||||
@@ -59,7 +59,7 @@ export function handleAgentEnd(ctx: EmbeddedPiSubscribeContext) {
|
|||||||
endedAt: Date.now(),
|
endedAt: Date.now(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
ctx.params.onAgentEvent?.({
|
void ctx.params.onAgentEvent?.({
|
||||||
stream: "lifecycle",
|
stream: "lifecycle",
|
||||||
data: { phase: "end" },
|
data: { phase: "end" },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export function handleMessageUpdate(
|
|||||||
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
|
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
ctx.params.onAgentEvent?.({
|
void ctx.params.onAgentEvent?.({
|
||||||
stream: "assistant",
|
stream: "assistant",
|
||||||
data: {
|
data: {
|
||||||
text: cleanedText,
|
text: cleanedText,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
} from "./pi-embedded-subscribe.tools.js";
|
} from "./pi-embedded-subscribe.tools.js";
|
||||||
import { inferToolMetaFromArgs } from "./pi-embedded-utils.js";
|
import { inferToolMetaFromArgs } from "./pi-embedded-utils.js";
|
||||||
|
|
||||||
export function handleToolExecutionStart(
|
export async function handleToolExecutionStart(
|
||||||
ctx: EmbeddedPiSubscribeContext,
|
ctx: EmbeddedPiSubscribeContext,
|
||||||
evt: AgentEvent & { toolName: string; toolCallId: string; args: unknown },
|
evt: AgentEvent & { toolName: string; toolCallId: string; args: unknown },
|
||||||
) {
|
) {
|
||||||
@@ -53,7 +53,8 @@ export function handleToolExecutionStart(
|
|||||||
args: args as Record<string, unknown>,
|
args: args as Record<string, unknown>,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
ctx.params.onAgentEvent?.({
|
// Await onAgentEvent to ensure typing indicator starts before tool summaries are emitted.
|
||||||
|
await ctx.params.onAgentEvent?.({
|
||||||
stream: "tool",
|
stream: "tool",
|
||||||
data: { phase: "start", name: toolName, toolCallId },
|
data: { phase: "start", name: toolName, toolCallId },
|
||||||
});
|
});
|
||||||
@@ -108,7 +109,7 @@ export function handleToolExecutionUpdate(
|
|||||||
partialResult: sanitized,
|
partialResult: sanitized,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
ctx.params.onAgentEvent?.({
|
void ctx.params.onAgentEvent?.({
|
||||||
stream: "tool",
|
stream: "tool",
|
||||||
data: {
|
data: {
|
||||||
phase: "update",
|
phase: "update",
|
||||||
@@ -170,7 +171,7 @@ export function handleToolExecutionEnd(
|
|||||||
result: sanitizedResult,
|
result: sanitizedResult,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
ctx.params.onAgentEvent?.({
|
void ctx.params.onAgentEvent?.({
|
||||||
stream: "tool",
|
stream: "tool",
|
||||||
data: {
|
data: {
|
||||||
phase: "result",
|
phase: "result",
|
||||||
|
|||||||
@@ -32,7 +32,11 @@ export function createEmbeddedPiSessionEventHandler(ctx: EmbeddedPiSubscribeCont
|
|||||||
handleMessageEnd(ctx, evt as never);
|
handleMessageEnd(ctx, evt as never);
|
||||||
return;
|
return;
|
||||||
case "tool_execution_start":
|
case "tool_execution_start":
|
||||||
handleToolExecutionStart(ctx, evt as never);
|
// Async handler - awaits typing indicator before emitting tool summaries.
|
||||||
|
// Catch rejections to avoid unhandled promise rejection crashes.
|
||||||
|
handleToolExecutionStart(ctx, evt as never).catch((err) => {
|
||||||
|
ctx.log.debug(`tool_execution_start handler failed: ${String(err)}`);
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
case "tool_execution_update":
|
case "tool_execution_update":
|
||||||
handleToolExecutionUpdate(ctx, evt as never);
|
handleToolExecutionUpdate(ctx, evt as never);
|
||||||
|
|||||||
+8
-2
@@ -13,7 +13,7 @@ describe("subscribeEmbeddedPiSession", () => {
|
|||||||
{ tag: "antthinking", open: "<antthinking>", close: "</antthinking>" },
|
{ tag: "antthinking", open: "<antthinking>", close: "</antthinking>" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
it("includes canvas action metadata in tool summaries", () => {
|
it("includes canvas action metadata in tool summaries", async () => {
|
||||||
let handler: ((evt: unknown) => void) | undefined;
|
let handler: ((evt: unknown) => void) | undefined;
|
||||||
const session: StubSession = {
|
const session: StubSession = {
|
||||||
subscribe: (fn) => {
|
subscribe: (fn) => {
|
||||||
@@ -38,6 +38,9 @@ describe("subscribeEmbeddedPiSession", () => {
|
|||||||
args: { action: "a2ui_push", jsonlPath: "/tmp/a2ui.jsonl" },
|
args: { action: "a2ui_push", jsonlPath: "/tmp/a2ui.jsonl" },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Wait for async handler to complete
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
expect(onToolResult).toHaveBeenCalledTimes(1);
|
expect(onToolResult).toHaveBeenCalledTimes(1);
|
||||||
const payload = onToolResult.mock.calls[0][0];
|
const payload = onToolResult.mock.calls[0][0];
|
||||||
expect(payload.text).toContain("🖼️");
|
expect(payload.text).toContain("🖼️");
|
||||||
@@ -72,7 +75,7 @@ describe("subscribeEmbeddedPiSession", () => {
|
|||||||
|
|
||||||
expect(onToolResult).not.toHaveBeenCalled();
|
expect(onToolResult).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
it("emits tool summaries when shouldEmitToolResult overrides verbose", () => {
|
it("emits tool summaries when shouldEmitToolResult overrides verbose", async () => {
|
||||||
let handler: ((evt: unknown) => void) | undefined;
|
let handler: ((evt: unknown) => void) | undefined;
|
||||||
const session: StubSession = {
|
const session: StubSession = {
|
||||||
subscribe: (fn) => {
|
subscribe: (fn) => {
|
||||||
@@ -98,6 +101,9 @@ describe("subscribeEmbeddedPiSession", () => {
|
|||||||
args: { path: "/tmp/c.txt" },
|
args: { path: "/tmp/c.txt" },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Wait for async handler to complete
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
expect(onToolResult).toHaveBeenCalledTimes(1);
|
expect(onToolResult).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+8
-2
@@ -14,7 +14,7 @@ describe("subscribeEmbeddedPiSession", () => {
|
|||||||
{ tag: "antthinking", open: "<antthinking>", close: "</antthinking>" },
|
{ tag: "antthinking", open: "<antthinking>", close: "</antthinking>" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
it("suppresses message_end block replies when the message tool already sent", () => {
|
it("suppresses message_end block replies when the message tool already sent", async () => {
|
||||||
let handler: ((evt: unknown) => void) | undefined;
|
let handler: ((evt: unknown) => void) | undefined;
|
||||||
const session: StubSession = {
|
const session: StubSession = {
|
||||||
subscribe: (fn) => {
|
subscribe: (fn) => {
|
||||||
@@ -41,6 +41,9 @@ describe("subscribeEmbeddedPiSession", () => {
|
|||||||
args: { action: "send", to: "+1555", message: messageText },
|
args: { action: "send", to: "+1555", message: messageText },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Wait for async handler to complete
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
handler?.({
|
handler?.({
|
||||||
type: "tool_execution_end",
|
type: "tool_execution_end",
|
||||||
toolName: "message",
|
toolName: "message",
|
||||||
@@ -58,7 +61,7 @@ describe("subscribeEmbeddedPiSession", () => {
|
|||||||
|
|
||||||
expect(onBlockReply).not.toHaveBeenCalled();
|
expect(onBlockReply).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
it("does not suppress message_end replies when message tool reports error", () => {
|
it("does not suppress message_end replies when message tool reports error", async () => {
|
||||||
let handler: ((evt: unknown) => void) | undefined;
|
let handler: ((evt: unknown) => void) | undefined;
|
||||||
const session: StubSession = {
|
const session: StubSession = {
|
||||||
subscribe: (fn) => {
|
subscribe: (fn) => {
|
||||||
@@ -85,6 +88,9 @@ describe("subscribeEmbeddedPiSession", () => {
|
|||||||
args: { action: "send", to: "+1555", message: messageText },
|
args: { action: "send", to: "+1555", message: messageText },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Wait for async handler to complete
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
handler?.({
|
handler?.({
|
||||||
type: "tool_execution_end",
|
type: "tool_execution_end",
|
||||||
toolName: "message",
|
toolName: "message",
|
||||||
|
|||||||
+8
-2
@@ -54,7 +54,7 @@ describe("subscribeEmbeddedPiSession", () => {
|
|||||||
await waitPromise;
|
await waitPromise;
|
||||||
expect(resolved).toBe(true);
|
expect(resolved).toBe(true);
|
||||||
});
|
});
|
||||||
it("emits tool summaries at tool start when verbose is on", () => {
|
it("emits tool summaries at tool start when verbose is on", async () => {
|
||||||
let handler: ((evt: unknown) => void) | undefined;
|
let handler: ((evt: unknown) => void) | undefined;
|
||||||
const session: StubSession = {
|
const session: StubSession = {
|
||||||
subscribe: (fn) => {
|
subscribe: (fn) => {
|
||||||
@@ -79,6 +79,9 @@ describe("subscribeEmbeddedPiSession", () => {
|
|||||||
args: { path: "/tmp/a.txt" },
|
args: { path: "/tmp/a.txt" },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Wait for async handler to complete
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
expect(onToolResult).toHaveBeenCalledTimes(1);
|
expect(onToolResult).toHaveBeenCalledTimes(1);
|
||||||
const payload = onToolResult.mock.calls[0][0];
|
const payload = onToolResult.mock.calls[0][0];
|
||||||
expect(payload.text).toContain("/tmp/a.txt");
|
expect(payload.text).toContain("/tmp/a.txt");
|
||||||
@@ -93,7 +96,7 @@ describe("subscribeEmbeddedPiSession", () => {
|
|||||||
|
|
||||||
expect(onToolResult).toHaveBeenCalledTimes(1);
|
expect(onToolResult).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
it("includes browser action metadata in tool summaries", () => {
|
it("includes browser action metadata in tool summaries", async () => {
|
||||||
let handler: ((evt: unknown) => void) | undefined;
|
let handler: ((evt: unknown) => void) | undefined;
|
||||||
const session: StubSession = {
|
const session: StubSession = {
|
||||||
subscribe: (fn) => {
|
subscribe: (fn) => {
|
||||||
@@ -118,6 +121,9 @@ describe("subscribeEmbeddedPiSession", () => {
|
|||||||
args: { action: "snapshot", targetUrl: "https://example.com" },
|
args: { action: "snapshot", targetUrl: "https://example.com" },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Wait for async handler to complete
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
expect(onToolResult).toHaveBeenCalledTimes(1);
|
expect(onToolResult).toHaveBeenCalledTimes(1);
|
||||||
const payload = onToolResult.mock.calls[0][0];
|
const payload = onToolResult.mock.calls[0][0];
|
||||||
expect(payload.text).toContain("🌐");
|
expect(payload.text).toContain("🌐");
|
||||||
|
|||||||
@@ -22,7 +22,10 @@ export type SubscribeEmbeddedPiSessionParams = {
|
|||||||
blockReplyChunking?: BlockReplyChunking;
|
blockReplyChunking?: BlockReplyChunking;
|
||||||
onPartialReply?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
|
onPartialReply?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
|
||||||
onAssistantMessageStart?: () => void | Promise<void>;
|
onAssistantMessageStart?: () => void | Promise<void>;
|
||||||
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void;
|
onAgentEvent?: (evt: {
|
||||||
|
stream: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
}) => void | Promise<void>;
|
||||||
enforceFinalTag?: boolean;
|
enforceFinalTag?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export function ensureSkillsWatcher(params: { workspaceDir: string; config?: Cla
|
|||||||
if (!watchEnabled) {
|
if (!watchEnabled) {
|
||||||
if (existing) {
|
if (existing) {
|
||||||
watchers.delete(workspaceDir);
|
watchers.delete(workspaceDir);
|
||||||
existing.timer && clearTimeout(existing.timer);
|
if (existing.timer) clearTimeout(existing.timer);
|
||||||
void existing.watcher.close().catch(() => {});
|
void existing.watcher.close().catch(() => {});
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -115,7 +115,7 @@ export function ensureSkillsWatcher(params: { workspaceDir: string; config?: Cla
|
|||||||
}
|
}
|
||||||
if (existing) {
|
if (existing) {
|
||||||
watchers.delete(workspaceDir);
|
watchers.delete(workspaceDir);
|
||||||
existing.timer && clearTimeout(existing.timer);
|
if (existing.timer) clearTimeout(existing.timer);
|
||||||
void existing.watcher.close().catch(() => {});
|
void existing.watcher.close().catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ export function buildAgentSystemPrompt(params: {
|
|||||||
browser: "Control web browser",
|
browser: "Control web browser",
|
||||||
canvas: "Present/eval/snapshot the Canvas",
|
canvas: "Present/eval/snapshot the Canvas",
|
||||||
nodes: "List/describe/notify/camera/screen on paired nodes",
|
nodes: "List/describe/notify/camera/screen on paired nodes",
|
||||||
cron: "Manage cron jobs and wake events (use for reminders)",
|
cron: "Manage cron jobs and wake events (use for reminders; include recent context in reminder text if appropriate)",
|
||||||
message: "Send messages and channel actions",
|
message: "Send messages and channel actions",
|
||||||
gateway: "Restart, apply config, or run updates on the running Clawdbot process",
|
gateway: "Restart, apply config, or run updates on the running Clawdbot process",
|
||||||
agents_list: "List agent ids allowed for sessions_spawn",
|
agents_list: "List agent ids allowed for sessions_spawn",
|
||||||
@@ -319,7 +319,7 @@ export function buildAgentSystemPrompt(params: {
|
|||||||
"- browser: control clawd's dedicated browser",
|
"- browser: control clawd's dedicated browser",
|
||||||
"- canvas: present/eval/snapshot the Canvas",
|
"- canvas: present/eval/snapshot the Canvas",
|
||||||
"- nodes: list/describe/notify/camera/screen on paired nodes",
|
"- nodes: list/describe/notify/camera/screen on paired nodes",
|
||||||
"- cron: manage cron jobs and wake events (use for reminders)",
|
"- cron: manage cron jobs and wake events (use for reminders; include recent context in reminder text if appropriate)",
|
||||||
"- sessions_list: list sessions",
|
"- sessions_list: list sessions",
|
||||||
"- sessions_history: fetch session history",
|
"- sessions_history: fetch session history",
|
||||||
"- sessions_send: send to another session",
|
"- sessions_send: send to another session",
|
||||||
|
|||||||
@@ -84,4 +84,47 @@ describe("cron tool", () => {
|
|||||||
payload: { kind: "systemEvent", text: "hello" },
|
payload: { kind: "systemEvent", text: "hello" },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("adds recent context for systemEvent reminders when session key is available", async () => {
|
||||||
|
callGatewayMock
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
messages: [
|
||||||
|
{ role: "user", content: [{ type: "text", text: "Discussed Q2 budget" }] },
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text: "We agreed to review on Tuesday." }],
|
||||||
|
},
|
||||||
|
{ role: "user", content: [{ type: "text", text: "Remind me about the thing at 2pm" }] },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({ ok: true });
|
||||||
|
|
||||||
|
const tool = createCronTool({ agentSessionKey: "main" });
|
||||||
|
await tool.execute("call3", {
|
||||||
|
action: "add",
|
||||||
|
job: {
|
||||||
|
name: "reminder",
|
||||||
|
schedule: { atMs: 123 },
|
||||||
|
payload: { text: "Reminder: the thing." },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(callGatewayMock).toHaveBeenCalledTimes(2);
|
||||||
|
const historyCall = callGatewayMock.mock.calls[0]?.[0] as {
|
||||||
|
method?: string;
|
||||||
|
params?: unknown;
|
||||||
|
};
|
||||||
|
expect(historyCall.method).toBe("chat.history");
|
||||||
|
|
||||||
|
const cronCall = callGatewayMock.mock.calls[1]?.[0] as {
|
||||||
|
method?: string;
|
||||||
|
params?: { payload?: { text?: string } };
|
||||||
|
};
|
||||||
|
expect(cronCall.method).toBe("cron.add");
|
||||||
|
const text = cronCall.params?.payload?.text ?? "";
|
||||||
|
expect(text).toContain("Recent context:");
|
||||||
|
expect(text).toContain("User: Discussed Q2 budget");
|
||||||
|
expect(text).toContain("Assistant: We agreed to review on Tuesday.");
|
||||||
|
expect(text).toContain("User: Remind me about the thing at 2pm");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import { Type } from "@sinclair/typebox";
|
import { Type } from "@sinclair/typebox";
|
||||||
import { normalizeCronJobCreate, normalizeCronJobPatch } from "../../cron/normalize.js";
|
import { normalizeCronJobCreate, normalizeCronJobPatch } from "../../cron/normalize.js";
|
||||||
|
import { loadConfig } from "../../config/config.js";
|
||||||
|
import { truncateUtf16Safe } from "../../utils.js";
|
||||||
import { optionalStringEnum, stringEnum } from "../schema/typebox.js";
|
import { optionalStringEnum, stringEnum } from "../schema/typebox.js";
|
||||||
import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
|
import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js";
|
||||||
import { callGatewayTool, type GatewayCallOptions } from "./gateway.js";
|
import { callGatewayTool, type GatewayCallOptions } from "./gateway.js";
|
||||||
|
import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-helpers.js";
|
||||||
|
|
||||||
// NOTE: We use Type.Object({}, { additionalProperties: true }) for job/patch
|
// NOTE: We use Type.Object({}, { additionalProperties: true }) for job/patch
|
||||||
// instead of CronAddParamsSchema/CronJobPatchSchema because the gateway schemas
|
// instead of CronAddParamsSchema/CronJobPatchSchema because the gateway schemas
|
||||||
@@ -13,6 +16,11 @@ const CRON_ACTIONS = ["status", "list", "add", "update", "remove", "run", "runs"
|
|||||||
|
|
||||||
const CRON_WAKE_MODES = ["now", "next-heartbeat"] as const;
|
const CRON_WAKE_MODES = ["now", "next-heartbeat"] as const;
|
||||||
|
|
||||||
|
const REMINDER_CONTEXT_MESSAGES = 3;
|
||||||
|
const REMINDER_CONTEXT_PER_MESSAGE_MAX = 220;
|
||||||
|
const REMINDER_CONTEXT_TOTAL_MAX = 700;
|
||||||
|
const REMINDER_CONTEXT_MARKER = "\n\nRecent context:\n";
|
||||||
|
|
||||||
// Flattened schema: runtime validates per-action requirements.
|
// Flattened schema: runtime validates per-action requirements.
|
||||||
const CronToolSchema = Type.Object({
|
const CronToolSchema = Type.Object({
|
||||||
action: stringEnum(CRON_ACTIONS),
|
action: stringEnum(CRON_ACTIONS),
|
||||||
@@ -28,7 +36,90 @@ const CronToolSchema = Type.Object({
|
|||||||
mode: optionalStringEnum(CRON_WAKE_MODES),
|
mode: optionalStringEnum(CRON_WAKE_MODES),
|
||||||
});
|
});
|
||||||
|
|
||||||
export function createCronTool(): AnyAgentTool {
|
type CronToolOptions = {
|
||||||
|
agentSessionKey?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ChatMessage = {
|
||||||
|
role?: unknown;
|
||||||
|
content?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function stripExistingContext(text: string) {
|
||||||
|
const index = text.indexOf(REMINDER_CONTEXT_MARKER);
|
||||||
|
if (index === -1) return text;
|
||||||
|
return text.slice(0, index).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateText(input: string, maxLen: number) {
|
||||||
|
if (input.length <= maxLen) return input;
|
||||||
|
const truncated = truncateUtf16Safe(input, Math.max(0, maxLen - 3)).trimEnd();
|
||||||
|
return `${truncated}...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeContextText(raw: string) {
|
||||||
|
return raw.replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractMessageText(message: ChatMessage): { role: string; text: string } | null {
|
||||||
|
const role = typeof message.role === "string" ? message.role : "";
|
||||||
|
if (role !== "user" && role !== "assistant") return null;
|
||||||
|
const content = message.content;
|
||||||
|
if (typeof content === "string") {
|
||||||
|
const normalized = normalizeContextText(content);
|
||||||
|
return normalized ? { role, text: normalized } : null;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(content)) return null;
|
||||||
|
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 = normalizeContextText(chunks.join(" "));
|
||||||
|
return joined ? { role, text: joined } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildReminderContextLines(params: {
|
||||||
|
agentSessionKey?: string;
|
||||||
|
gatewayOpts: GatewayCallOptions;
|
||||||
|
}) {
|
||||||
|
const sessionKey = params.agentSessionKey?.trim();
|
||||||
|
if (!sessionKey) return [];
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const { mainKey, alias } = resolveMainSessionAlias(cfg);
|
||||||
|
const resolvedKey = resolveInternalSessionKey({ key: sessionKey, alias, mainKey });
|
||||||
|
try {
|
||||||
|
const res = (await callGatewayTool("chat.history", params.gatewayOpts, {
|
||||||
|
sessionKey: resolvedKey,
|
||||||
|
limit: 12,
|
||||||
|
})) as { messages?: unknown[] };
|
||||||
|
const messages = Array.isArray(res?.messages) ? res.messages : [];
|
||||||
|
const parsed = messages
|
||||||
|
.map((msg) => extractMessageText(msg as ChatMessage))
|
||||||
|
.filter((msg): msg is { role: string; text: string } => Boolean(msg));
|
||||||
|
const recent = parsed.slice(-REMINDER_CONTEXT_MESSAGES);
|
||||||
|
if (recent.length === 0) return [];
|
||||||
|
const lines: string[] = [];
|
||||||
|
let total = 0;
|
||||||
|
for (const entry of recent) {
|
||||||
|
const label = entry.role === "user" ? "User" : "Assistant";
|
||||||
|
const text = truncateText(entry.text, REMINDER_CONTEXT_PER_MESSAGE_MAX);
|
||||||
|
const line = `- ${label}: ${text}`;
|
||||||
|
total += line.length;
|
||||||
|
if (total > REMINDER_CONTEXT_TOTAL_MAX) break;
|
||||||
|
lines.push(line);
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCronTool(opts?: CronToolOptions): AnyAgentTool {
|
||||||
return {
|
return {
|
||||||
label: "Cron",
|
label: "Cron",
|
||||||
name: "cron",
|
name: "cron",
|
||||||
@@ -58,6 +149,24 @@ export function createCronTool(): AnyAgentTool {
|
|||||||
throw new Error("job required");
|
throw new Error("job required");
|
||||||
}
|
}
|
||||||
const job = normalizeCronJobCreate(params.job) ?? params.job;
|
const job = normalizeCronJobCreate(params.job) ?? params.job;
|
||||||
|
if (
|
||||||
|
job &&
|
||||||
|
typeof job === "object" &&
|
||||||
|
"payload" in job &&
|
||||||
|
(job as { payload?: { kind?: string; text?: string } }).payload?.kind === "systemEvent"
|
||||||
|
) {
|
||||||
|
const payload = (job as { payload: { kind: string; text: string } }).payload;
|
||||||
|
if (typeof payload.text === "string" && payload.text.trim()) {
|
||||||
|
const contextLines = await buildReminderContextLines({
|
||||||
|
agentSessionKey: opts?.agentSessionKey,
|
||||||
|
gatewayOpts,
|
||||||
|
});
|
||||||
|
if (contextLines.length > 0) {
|
||||||
|
const baseText = stripExistingContext(payload.text);
|
||||||
|
payload.text = `${baseText}${REMINDER_CONTEXT_MARKER}${contextLines.join("\n")}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return jsonResult(await callGatewayTool("cron.add", gatewayOpts, job));
|
return jsonResult(await callGatewayTool("cron.add", gatewayOpts, job));
|
||||||
}
|
}
|
||||||
case "update": {
|
case "update": {
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ export async function handleDiscordGuildAction(
|
|||||||
return jsonResult({ ok: true, event });
|
return jsonResult({ ok: true, event });
|
||||||
}
|
}
|
||||||
case "channelCreate": {
|
case "channelCreate": {
|
||||||
if (!isActionEnabled("channels", false)) {
|
if (!isActionEnabled("channels")) {
|
||||||
throw new Error("Discord channel management is disabled.");
|
throw new Error("Discord channel management is disabled.");
|
||||||
}
|
}
|
||||||
const guildId = readStringParam(params, "guildId", { required: true });
|
const guildId = readStringParam(params, "guildId", { required: true });
|
||||||
@@ -241,7 +241,7 @@ export async function handleDiscordGuildAction(
|
|||||||
return jsonResult({ ok: true, channel });
|
return jsonResult({ ok: true, channel });
|
||||||
}
|
}
|
||||||
case "channelEdit": {
|
case "channelEdit": {
|
||||||
if (!isActionEnabled("channels", false)) {
|
if (!isActionEnabled("channels")) {
|
||||||
throw new Error("Discord channel management is disabled.");
|
throw new Error("Discord channel management is disabled.");
|
||||||
}
|
}
|
||||||
const channelId = readStringParam(params, "channelId", {
|
const channelId = readStringParam(params, "channelId", {
|
||||||
@@ -267,7 +267,7 @@ export async function handleDiscordGuildAction(
|
|||||||
return jsonResult({ ok: true, channel });
|
return jsonResult({ ok: true, channel });
|
||||||
}
|
}
|
||||||
case "channelDelete": {
|
case "channelDelete": {
|
||||||
if (!isActionEnabled("channels", false)) {
|
if (!isActionEnabled("channels")) {
|
||||||
throw new Error("Discord channel management is disabled.");
|
throw new Error("Discord channel management is disabled.");
|
||||||
}
|
}
|
||||||
const channelId = readStringParam(params, "channelId", {
|
const channelId = readStringParam(params, "channelId", {
|
||||||
@@ -277,7 +277,7 @@ export async function handleDiscordGuildAction(
|
|||||||
return jsonResult(result);
|
return jsonResult(result);
|
||||||
}
|
}
|
||||||
case "channelMove": {
|
case "channelMove": {
|
||||||
if (!isActionEnabled("channels", false)) {
|
if (!isActionEnabled("channels")) {
|
||||||
throw new Error("Discord channel management is disabled.");
|
throw new Error("Discord channel management is disabled.");
|
||||||
}
|
}
|
||||||
const guildId = readStringParam(params, "guildId", { required: true });
|
const guildId = readStringParam(params, "guildId", { required: true });
|
||||||
@@ -295,7 +295,7 @@ export async function handleDiscordGuildAction(
|
|||||||
return jsonResult({ ok: true });
|
return jsonResult({ ok: true });
|
||||||
}
|
}
|
||||||
case "categoryCreate": {
|
case "categoryCreate": {
|
||||||
if (!isActionEnabled("channels", false)) {
|
if (!isActionEnabled("channels")) {
|
||||||
throw new Error("Discord channel management is disabled.");
|
throw new Error("Discord channel management is disabled.");
|
||||||
}
|
}
|
||||||
const guildId = readStringParam(params, "guildId", { required: true });
|
const guildId = readStringParam(params, "guildId", { required: true });
|
||||||
@@ -310,7 +310,7 @@ export async function handleDiscordGuildAction(
|
|||||||
return jsonResult({ ok: true, category: channel });
|
return jsonResult({ ok: true, category: channel });
|
||||||
}
|
}
|
||||||
case "categoryEdit": {
|
case "categoryEdit": {
|
||||||
if (!isActionEnabled("channels", false)) {
|
if (!isActionEnabled("channels")) {
|
||||||
throw new Error("Discord channel management is disabled.");
|
throw new Error("Discord channel management is disabled.");
|
||||||
}
|
}
|
||||||
const categoryId = readStringParam(params, "categoryId", {
|
const categoryId = readStringParam(params, "categoryId", {
|
||||||
@@ -326,7 +326,7 @@ export async function handleDiscordGuildAction(
|
|||||||
return jsonResult({ ok: true, category: channel });
|
return jsonResult({ ok: true, category: channel });
|
||||||
}
|
}
|
||||||
case "categoryDelete": {
|
case "categoryDelete": {
|
||||||
if (!isActionEnabled("channels", false)) {
|
if (!isActionEnabled("channels")) {
|
||||||
throw new Error("Discord channel management is disabled.");
|
throw new Error("Discord channel management is disabled.");
|
||||||
}
|
}
|
||||||
const categoryId = readStringParam(params, "categoryId", {
|
const categoryId = readStringParam(params, "categoryId", {
|
||||||
@@ -336,7 +336,7 @@ export async function handleDiscordGuildAction(
|
|||||||
return jsonResult(result);
|
return jsonResult(result);
|
||||||
}
|
}
|
||||||
case "channelPermissionSet": {
|
case "channelPermissionSet": {
|
||||||
if (!isActionEnabled("channels", false)) {
|
if (!isActionEnabled("channels")) {
|
||||||
throw new Error("Discord channel management is disabled.");
|
throw new Error("Discord channel management is disabled.");
|
||||||
}
|
}
|
||||||
const channelId = readStringParam(params, "channelId", {
|
const channelId = readStringParam(params, "channelId", {
|
||||||
@@ -359,7 +359,7 @@ export async function handleDiscordGuildAction(
|
|||||||
return jsonResult({ ok: true });
|
return jsonResult({ ok: true });
|
||||||
}
|
}
|
||||||
case "channelPermissionRemove": {
|
case "channelPermissionRemove": {
|
||||||
if (!isActionEnabled("channels", false)) {
|
if (!isActionEnabled("channels")) {
|
||||||
throw new Error("Discord channel management is disabled.");
|
throw new Error("Discord channel management is disabled.");
|
||||||
}
|
}
|
||||||
const channelId = readStringParam(params, "channelId", {
|
const channelId = readStringParam(params, "channelId", {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import type { VerboseLevel } from "../thinking.js";
|
|||||||
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js";
|
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||||
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
||||||
import { buildThreadingToolContext, resolveEnforceFinalTag } from "./agent-runner-utils.js";
|
import { buildThreadingToolContext, resolveEnforceFinalTag } from "./agent-runner-utils.js";
|
||||||
import type { BlockReplyPipeline } from "./block-reply-pipeline.js";
|
import { createBlockReplyPayloadKey, type BlockReplyPipeline } from "./block-reply-pipeline.js";
|
||||||
import type { FollowupRun } from "./queue.js";
|
import type { FollowupRun } from "./queue.js";
|
||||||
import { parseReplyDirectives } from "./reply-directives.js";
|
import { parseReplyDirectives } from "./reply-directives.js";
|
||||||
import { applyReplyTagsToPayload, isRenderablePayload } from "./reply-payloads.js";
|
import { applyReplyTagsToPayload, isRenderablePayload } from "./reply-payloads.js";
|
||||||
@@ -40,6 +40,8 @@ export type AgentRunLoopResult =
|
|||||||
fallbackModel?: string;
|
fallbackModel?: string;
|
||||||
didLogHeartbeatStrip: boolean;
|
didLogHeartbeatStrip: boolean;
|
||||||
autoCompactionCompleted: boolean;
|
autoCompactionCompleted: boolean;
|
||||||
|
/** Payload keys sent directly (not via pipeline) during tool flush. */
|
||||||
|
directlySentBlockKeys?: Set<string>;
|
||||||
}
|
}
|
||||||
| { kind: "final"; payload: ReplyPayload };
|
| { kind: "final"; payload: ReplyPayload };
|
||||||
|
|
||||||
@@ -70,6 +72,8 @@ export async function runAgentTurnWithFallback(params: {
|
|||||||
}): Promise<AgentRunLoopResult> {
|
}): Promise<AgentRunLoopResult> {
|
||||||
let didLogHeartbeatStrip = false;
|
let didLogHeartbeatStrip = false;
|
||||||
let autoCompactionCompleted = false;
|
let autoCompactionCompleted = false;
|
||||||
|
// Track payloads sent directly (not via pipeline) during tool flush to avoid duplicates.
|
||||||
|
const directlySentBlockKeys = new Set<string>();
|
||||||
|
|
||||||
const runId = crypto.randomUUID();
|
const runId = crypto.randomUUID();
|
||||||
if (params.sessionKey) {
|
if (params.sessionKey) {
|
||||||
@@ -244,12 +248,13 @@ export async function runAgentTurnWithFallback(params: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
onAgentEvent: (evt) => {
|
onAgentEvent: async (evt) => {
|
||||||
// Trigger typing when tools start executing
|
// Trigger typing when tools start executing.
|
||||||
|
// Must await to ensure typing indicator starts before tool summaries are emitted.
|
||||||
if (evt.stream === "tool") {
|
if (evt.stream === "tool") {
|
||||||
const phase = typeof evt.data.phase === "string" ? evt.data.phase : "";
|
const phase = typeof evt.data.phase === "string" ? evt.data.phase : "";
|
||||||
if (phase === "start" || phase === "update") {
|
if (phase === "start" || phase === "update") {
|
||||||
void params.typingSignals.signalToolStart();
|
await params.typingSignals.signalToolStart();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Track auto-compaction completion
|
// Track auto-compaction completion
|
||||||
@@ -261,57 +266,67 @@ export async function runAgentTurnWithFallback(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onBlockReply:
|
// Always pass onBlockReply so flushBlockReplyBuffer works before tool execution,
|
||||||
params.blockStreamingEnabled && params.opts?.onBlockReply
|
// even when regular block streaming is disabled. The handler sends directly
|
||||||
? async (payload) => {
|
// via opts.onBlockReply when the pipeline isn't available.
|
||||||
const { text, skip } = normalizeStreamingText(payload);
|
onBlockReply: params.opts?.onBlockReply
|
||||||
const hasPayloadMedia = (payload.mediaUrls?.length ?? 0) > 0;
|
? async (payload) => {
|
||||||
if (skip && !hasPayloadMedia) return;
|
const { text, skip } = normalizeStreamingText(payload);
|
||||||
const taggedPayload = applyReplyTagsToPayload(
|
const hasPayloadMedia = (payload.mediaUrls?.length ?? 0) > 0;
|
||||||
{
|
if (skip && !hasPayloadMedia) return;
|
||||||
text,
|
const taggedPayload = applyReplyTagsToPayload(
|
||||||
mediaUrls: payload.mediaUrls,
|
{
|
||||||
mediaUrl: payload.mediaUrls?.[0],
|
text,
|
||||||
},
|
mediaUrls: payload.mediaUrls,
|
||||||
params.sessionCtx.MessageSid,
|
mediaUrl: payload.mediaUrls?.[0],
|
||||||
);
|
},
|
||||||
// Let through payloads with audioAsVoice flag even if empty (need to track it)
|
params.sessionCtx.MessageSid,
|
||||||
if (!isRenderablePayload(taggedPayload) && !payload.audioAsVoice) return;
|
);
|
||||||
const parsed = parseReplyDirectives(taggedPayload.text ?? "", {
|
// Let through payloads with audioAsVoice flag even if empty (need to track it)
|
||||||
currentMessageId: params.sessionCtx.MessageSid,
|
if (!isRenderablePayload(taggedPayload) && !payload.audioAsVoice) return;
|
||||||
silentToken: SILENT_REPLY_TOKEN,
|
const parsed = parseReplyDirectives(taggedPayload.text ?? "", {
|
||||||
});
|
currentMessageId: params.sessionCtx.MessageSid,
|
||||||
const cleaned = parsed.text || undefined;
|
silentToken: SILENT_REPLY_TOKEN,
|
||||||
const hasRenderableMedia =
|
});
|
||||||
Boolean(taggedPayload.mediaUrl) || (taggedPayload.mediaUrls?.length ?? 0) > 0;
|
const cleaned = parsed.text || undefined;
|
||||||
// Skip empty payloads unless they have audioAsVoice flag (need to track it)
|
const hasRenderableMedia =
|
||||||
if (
|
Boolean(taggedPayload.mediaUrl) || (taggedPayload.mediaUrls?.length ?? 0) > 0;
|
||||||
!cleaned &&
|
// Skip empty payloads unless they have audioAsVoice flag (need to track it)
|
||||||
!hasRenderableMedia &&
|
if (
|
||||||
!payload.audioAsVoice &&
|
!cleaned &&
|
||||||
!parsed.audioAsVoice
|
!hasRenderableMedia &&
|
||||||
)
|
!payload.audioAsVoice &&
|
||||||
return;
|
!parsed.audioAsVoice
|
||||||
if (parsed.isSilent && !hasRenderableMedia) return;
|
)
|
||||||
|
return;
|
||||||
|
if (parsed.isSilent && !hasRenderableMedia) return;
|
||||||
|
|
||||||
const blockPayload: ReplyPayload = params.applyReplyToMode({
|
const blockPayload: ReplyPayload = params.applyReplyToMode({
|
||||||
...taggedPayload,
|
...taggedPayload,
|
||||||
text: cleaned,
|
text: cleaned,
|
||||||
audioAsVoice: Boolean(parsed.audioAsVoice || payload.audioAsVoice),
|
audioAsVoice: Boolean(parsed.audioAsVoice || payload.audioAsVoice),
|
||||||
replyToId: taggedPayload.replyToId ?? parsed.replyToId,
|
replyToId: taggedPayload.replyToId ?? parsed.replyToId,
|
||||||
replyToTag: taggedPayload.replyToTag || parsed.replyToTag,
|
replyToTag: taggedPayload.replyToTag || parsed.replyToTag,
|
||||||
replyToCurrent: taggedPayload.replyToCurrent || parsed.replyToCurrent,
|
replyToCurrent: taggedPayload.replyToCurrent || parsed.replyToCurrent,
|
||||||
|
});
|
||||||
|
|
||||||
|
void params.typingSignals
|
||||||
|
.signalTextDelta(cleaned ?? taggedPayload.text)
|
||||||
|
.catch((err) => {
|
||||||
|
logVerbose(`block reply typing signal failed: ${String(err)}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
void params.typingSignals
|
// Use pipeline if available (block streaming enabled), otherwise send directly
|
||||||
.signalTextDelta(cleaned ?? taggedPayload.text)
|
if (params.blockStreamingEnabled && params.blockReplyPipeline) {
|
||||||
.catch((err) => {
|
params.blockReplyPipeline.enqueue(blockPayload);
|
||||||
logVerbose(`block reply typing signal failed: ${String(err)}`);
|
} else {
|
||||||
});
|
// Send directly when flushing before tool execution (no streaming).
|
||||||
|
// Track sent key to avoid duplicate in final payloads.
|
||||||
params.blockReplyPipeline?.enqueue(blockPayload);
|
directlySentBlockKeys.add(createBlockReplyPayloadKey(blockPayload));
|
||||||
|
await params.opts?.onBlockReply?.(blockPayload);
|
||||||
}
|
}
|
||||||
: undefined,
|
}
|
||||||
|
: undefined,
|
||||||
onBlockReplyFlush:
|
onBlockReplyFlush:
|
||||||
params.blockStreamingEnabled && blockReplyPipeline
|
params.blockStreamingEnabled && blockReplyPipeline
|
||||||
? async () => {
|
? async () => {
|
||||||
@@ -447,5 +462,6 @@ export async function runAgentTurnWithFallback(params: {
|
|||||||
fallbackModel,
|
fallbackModel,
|
||||||
didLogHeartbeatStrip,
|
didLogHeartbeatStrip,
|
||||||
autoCompactionCompleted,
|
autoCompactionCompleted,
|
||||||
|
directlySentBlockKeys: directlySentBlockKeys.size > 0 ? directlySentBlockKeys : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,7 @@ import type { OriginatingChannelType } from "../templating.js";
|
|||||||
import { SILENT_REPLY_TOKEN } from "../tokens.js";
|
import { SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||||
import type { ReplyPayload } from "../types.js";
|
import type { ReplyPayload } from "../types.js";
|
||||||
import { formatBunFetchSocketError, isBunFetchSocketError } from "./agent-runner-utils.js";
|
import { formatBunFetchSocketError, isBunFetchSocketError } from "./agent-runner-utils.js";
|
||||||
import type { BlockReplyPipeline } from "./block-reply-pipeline.js";
|
import { createBlockReplyPayloadKey, type BlockReplyPipeline } from "./block-reply-pipeline.js";
|
||||||
import { parseReplyDirectives } from "./reply-directives.js";
|
import { parseReplyDirectives } from "./reply-directives.js";
|
||||||
import {
|
import {
|
||||||
applyReplyThreading,
|
applyReplyThreading,
|
||||||
@@ -20,6 +20,8 @@ export function buildReplyPayloads(params: {
|
|||||||
didLogHeartbeatStrip: boolean;
|
didLogHeartbeatStrip: boolean;
|
||||||
blockStreamingEnabled: boolean;
|
blockStreamingEnabled: boolean;
|
||||||
blockReplyPipeline: BlockReplyPipeline | null;
|
blockReplyPipeline: BlockReplyPipeline | null;
|
||||||
|
/** Payload keys sent directly (not via pipeline) during tool flush. */
|
||||||
|
directlySentBlockKeys?: Set<string>;
|
||||||
replyToMode: ReplyToMode;
|
replyToMode: ReplyToMode;
|
||||||
replyToChannel?: OriginatingChannelType;
|
replyToChannel?: OriginatingChannelType;
|
||||||
currentMessageId?: string;
|
currentMessageId?: string;
|
||||||
@@ -98,11 +100,16 @@ export function buildReplyPayloads(params: {
|
|||||||
payloads: replyTaggedPayloads,
|
payloads: replyTaggedPayloads,
|
||||||
sentTexts: messagingToolSentTexts,
|
sentTexts: messagingToolSentTexts,
|
||||||
});
|
});
|
||||||
|
// Filter out payloads already sent via pipeline or directly during tool flush.
|
||||||
const filteredPayloads = shouldDropFinalPayloads
|
const filteredPayloads = shouldDropFinalPayloads
|
||||||
? []
|
? []
|
||||||
: params.blockStreamingEnabled
|
: params.blockStreamingEnabled
|
||||||
? dedupedPayloads.filter((payload) => !params.blockReplyPipeline?.hasSentPayload(payload))
|
? dedupedPayloads.filter((payload) => !params.blockReplyPipeline?.hasSentPayload(payload))
|
||||||
: dedupedPayloads;
|
: params.directlySentBlockKeys?.size
|
||||||
|
? dedupedPayloads.filter(
|
||||||
|
(payload) => !params.directlySentBlockKeys!.has(createBlockReplyPayloadKey(payload)),
|
||||||
|
)
|
||||||
|
: dedupedPayloads;
|
||||||
const replyPayloads = suppressMessagingToolReplies ? [] : filteredPayloads;
|
const replyPayloads = suppressMessagingToolReplies ? [] : filteredPayloads;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -272,7 +272,7 @@ export async function runReplyAgent(params: {
|
|||||||
return finalizeWithFollowup(runOutcome.payload, queueKey, runFollowupTurn);
|
return finalizeWithFollowup(runOutcome.payload, queueKey, runFollowupTurn);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { runResult, fallbackProvider, fallbackModel } = runOutcome;
|
const { runResult, fallbackProvider, fallbackModel, directlySentBlockKeys } = runOutcome;
|
||||||
let { didLogHeartbeatStrip, autoCompactionCompleted } = runOutcome;
|
let { didLogHeartbeatStrip, autoCompactionCompleted } = runOutcome;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -314,6 +314,7 @@ export async function runReplyAgent(params: {
|
|||||||
didLogHeartbeatStrip,
|
didLogHeartbeatStrip,
|
||||||
blockStreamingEnabled,
|
blockStreamingEnabled,
|
||||||
blockReplyPipeline,
|
blockReplyPipeline,
|
||||||
|
directlySentBlockKeys,
|
||||||
replyToMode,
|
replyToMode,
|
||||||
replyToChannel,
|
replyToChannel,
|
||||||
currentMessageId: sessionCtx.MessageSid,
|
currentMessageId: sessionCtx.MessageSid,
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ export type BrowserStatus = {
|
|||||||
cdpPort: number;
|
cdpPort: number;
|
||||||
cdpUrl?: string;
|
cdpUrl?: string;
|
||||||
chosenBrowser: string | null;
|
chosenBrowser: string | null;
|
||||||
|
detectedBrowser?: string | null;
|
||||||
|
detectedExecutablePath?: string | null;
|
||||||
|
detectError?: string | null;
|
||||||
userDataDir: string | null;
|
userDataDir: string | null;
|
||||||
color: string;
|
color: string;
|
||||||
headless: boolean;
|
headless: boolean;
|
||||||
|
|||||||
@@ -63,7 +63,11 @@ export async function requirePwAi(
|
|||||||
jsonError(
|
jsonError(
|
||||||
res,
|
res,
|
||||||
501,
|
501,
|
||||||
`Playwright is not available in this gateway build; '${feature}' is unsupported.`,
|
[
|
||||||
|
`Playwright is not available in this gateway build; '${feature}' is unsupported.`,
|
||||||
|
"Install the full Playwright package (not playwright-core) and restart the gateway, or reinstall with browser support.",
|
||||||
|
"Docs: /tools/browser#playwright-requirement",
|
||||||
|
].join("\n"),
|
||||||
);
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type express from "express";
|
import type express from "express";
|
||||||
|
|
||||||
|
import { resolveBrowserExecutableForPlatform } from "../chrome.executables.js";
|
||||||
import { createBrowserProfilesService } from "../profiles-service.js";
|
import { createBrowserProfilesService } from "../profiles-service.js";
|
||||||
import type { BrowserRouteContext } from "../server-context.js";
|
import type { BrowserRouteContext } from "../server-context.js";
|
||||||
import { getProfileContext, jsonError, toStringOrEmpty } from "./utils.js";
|
import { getProfileContext, jsonError, toStringOrEmpty } from "./utils.js";
|
||||||
@@ -36,6 +37,19 @@ export function registerBrowserBasicRoutes(app: express.Express, ctx: BrowserRou
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const profileState = current.profiles.get(profileCtx.profile.name);
|
const profileState = current.profiles.get(profileCtx.profile.name);
|
||||||
|
let detectedBrowser: string | null = null;
|
||||||
|
let detectedExecutablePath: string | null = null;
|
||||||
|
let detectError: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const detected = resolveBrowserExecutableForPlatform(current.resolved, process.platform);
|
||||||
|
if (detected) {
|
||||||
|
detectedBrowser = detected.kind;
|
||||||
|
detectedExecutablePath = detected.path;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
detectError = String(err);
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
enabled: current.resolved.enabled,
|
enabled: current.resolved.enabled,
|
||||||
@@ -48,6 +62,9 @@ export function registerBrowserBasicRoutes(app: express.Express, ctx: BrowserRou
|
|||||||
cdpPort: profileCtx.profile.cdpPort,
|
cdpPort: profileCtx.profile.cdpPort,
|
||||||
cdpUrl: profileCtx.profile.cdpUrl,
|
cdpUrl: profileCtx.profile.cdpUrl,
|
||||||
chosenBrowser: profileState?.running?.exe.kind ?? null,
|
chosenBrowser: profileState?.running?.exe.kind ?? null,
|
||||||
|
detectedBrowser,
|
||||||
|
detectedExecutablePath,
|
||||||
|
detectError,
|
||||||
userDataDir: profileState?.running?.userDataDir ?? null,
|
userDataDir: profileState?.running?.userDataDir ?? null,
|
||||||
color: profileCtx.profile.color,
|
color: profileCtx.profile.color,
|
||||||
headless: current.resolved.headless,
|
headless: current.resolved.headless,
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
7a41e2cbe78e007eeaa03a65e18b8edf3fe17263de427426eaf58e946728eaf6
|
e8286176aaf2c0daf5f4b30dd221c3d7aa1aec8329d6865cac3d9636fd7f08f6
|
||||||
+15246
-17439
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { ClawdbotConfig } from "../../../config/config.js";
|
||||||
|
import { discordMessageActions } from "./discord.js";
|
||||||
|
|
||||||
|
describe("discord message actions", () => {
|
||||||
|
it("lists channel and upload actions by default", () => {
|
||||||
|
const cfg = { channels: { discord: { token: "d0" } } } as ClawdbotConfig;
|
||||||
|
const actions = discordMessageActions.listActions?.({ cfg }) ?? [];
|
||||||
|
|
||||||
|
expect(actions).toContain("emoji-upload");
|
||||||
|
expect(actions).toContain("sticker-upload");
|
||||||
|
expect(actions).toContain("channel-create");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects disabled channel actions", () => {
|
||||||
|
const cfg = {
|
||||||
|
channels: { discord: { token: "d0", actions: { channels: false } } },
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
const actions = discordMessageActions.listActions?.({ cfg }) ?? [];
|
||||||
|
|
||||||
|
expect(actions).not.toContain("channel-create");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -47,7 +47,7 @@ export const discordMessageActions: ChannelMessageActionAdapter = {
|
|||||||
actions.add("channel-info");
|
actions.add("channel-info");
|
||||||
actions.add("channel-list");
|
actions.add("channel-list");
|
||||||
}
|
}
|
||||||
if (gate("channels", false)) {
|
if (gate("channels")) {
|
||||||
actions.add("channel-create");
|
actions.add("channel-create");
|
||||||
actions.add("channel-edit");
|
actions.add("channel-edit");
|
||||||
actions.add("channel-delete");
|
actions.add("channel-delete");
|
||||||
|
|||||||
@@ -47,7 +47,10 @@ export function registerBrowserManageCommands(
|
|||||||
`cdpPort: ${status.cdpPort}`,
|
`cdpPort: ${status.cdpPort}`,
|
||||||
`cdpUrl: ${status.cdpUrl ?? `http://127.0.0.1:${status.cdpPort}`}`,
|
`cdpUrl: ${status.cdpUrl ?? `http://127.0.0.1:${status.cdpPort}`}`,
|
||||||
`browser: ${status.chosenBrowser ?? "unknown"}`,
|
`browser: ${status.chosenBrowser ?? "unknown"}`,
|
||||||
|
`detectedBrowser: ${status.detectedBrowser ?? "unknown"}`,
|
||||||
|
`detectedPath: ${status.detectedExecutablePath ?? status.executablePath ?? "auto"}`,
|
||||||
`profileColor: ${status.color}`,
|
`profileColor: ${status.color}`,
|
||||||
|
...(status.detectError ? [`detectError: ${status.detectError}`] : []),
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -0,0 +1,297 @@
|
|||||||
|
import JSON5 from "json5";
|
||||||
|
import type { Command } from "commander";
|
||||||
|
|
||||||
|
import { readConfigFileSnapshot, writeConfigFile } from "../config/config.js";
|
||||||
|
import { danger, info } from "../globals.js";
|
||||||
|
import { defaultRuntime } from "../runtime.js";
|
||||||
|
import { formatDocsLink } from "../terminal/links.js";
|
||||||
|
import { theme } from "../terminal/theme.js";
|
||||||
|
|
||||||
|
type PathSegment = string;
|
||||||
|
|
||||||
|
function isIndexSegment(raw: string): boolean {
|
||||||
|
return /^[0-9]+$/.test(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePath(raw: string): PathSegment[] {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return [];
|
||||||
|
const parts: string[] = [];
|
||||||
|
let current = "";
|
||||||
|
let i = 0;
|
||||||
|
while (i < trimmed.length) {
|
||||||
|
const ch = trimmed[i];
|
||||||
|
if (ch === "\\") {
|
||||||
|
const next = trimmed[i + 1];
|
||||||
|
if (next) current += next;
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === ".") {
|
||||||
|
if (current) parts.push(current);
|
||||||
|
current = "";
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "[") {
|
||||||
|
if (current) parts.push(current);
|
||||||
|
current = "";
|
||||||
|
const close = trimmed.indexOf("]", i);
|
||||||
|
if (close === -1) throw new Error(`Invalid path (missing "]"): ${raw}`);
|
||||||
|
const inside = trimmed.slice(i + 1, close).trim();
|
||||||
|
if (!inside) throw new Error(`Invalid path (empty "[]"): ${raw}`);
|
||||||
|
parts.push(inside);
|
||||||
|
i = close + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
current += ch;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
if (current) parts.push(current);
|
||||||
|
return parts.map((part) => part.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseValue(raw: string, opts: { json?: boolean }): unknown {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (opts.json) {
|
||||||
|
try {
|
||||||
|
return JSON5.parse(trimmed);
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`Failed to parse JSON5 value: ${String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON5.parse(trimmed);
|
||||||
|
} catch {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAtPath(root: unknown, path: PathSegment[]): { found: boolean; value?: unknown } {
|
||||||
|
let current: unknown = root;
|
||||||
|
for (const segment of path) {
|
||||||
|
if (!current || typeof current !== "object") return { found: false };
|
||||||
|
if (Array.isArray(current)) {
|
||||||
|
if (!isIndexSegment(segment)) return { found: false };
|
||||||
|
const index = Number.parseInt(segment, 10);
|
||||||
|
if (!Number.isFinite(index) || index < 0 || index >= current.length) {
|
||||||
|
return { found: false };
|
||||||
|
}
|
||||||
|
current = current[index];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const record = current as Record<string, unknown>;
|
||||||
|
if (!(segment in record)) return { found: false };
|
||||||
|
current = record[segment];
|
||||||
|
}
|
||||||
|
return { found: true, value: current };
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAtPath(root: Record<string, unknown>, path: PathSegment[], value: unknown): void {
|
||||||
|
let current: unknown = root;
|
||||||
|
for (let i = 0; i < path.length - 1; i += 1) {
|
||||||
|
const segment = path[i];
|
||||||
|
const next = path[i + 1];
|
||||||
|
const nextIsIndex = Boolean(next && isIndexSegment(next));
|
||||||
|
if (Array.isArray(current)) {
|
||||||
|
if (!isIndexSegment(segment)) {
|
||||||
|
throw new Error(`Expected numeric index for array segment "${segment}"`);
|
||||||
|
}
|
||||||
|
const index = Number.parseInt(segment, 10);
|
||||||
|
const existing = current[index];
|
||||||
|
if (!existing || typeof existing !== "object") {
|
||||||
|
current[index] = nextIsIndex ? [] : {};
|
||||||
|
}
|
||||||
|
current = current[index];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!current || typeof current !== "object") {
|
||||||
|
throw new Error(`Cannot traverse into "${segment}" (not an object)`);
|
||||||
|
}
|
||||||
|
const record = current as Record<string, unknown>;
|
||||||
|
const existing = record[segment];
|
||||||
|
if (!existing || typeof existing !== "object") {
|
||||||
|
record[segment] = nextIsIndex ? [] : {};
|
||||||
|
}
|
||||||
|
current = record[segment];
|
||||||
|
}
|
||||||
|
|
||||||
|
const last = path[path.length - 1];
|
||||||
|
if (Array.isArray(current)) {
|
||||||
|
if (!isIndexSegment(last)) {
|
||||||
|
throw new Error(`Expected numeric index for array segment "${last}"`);
|
||||||
|
}
|
||||||
|
const index = Number.parseInt(last, 10);
|
||||||
|
current[index] = value;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!current || typeof current !== "object") {
|
||||||
|
throw new Error(`Cannot set "${last}" (parent is not an object)`);
|
||||||
|
}
|
||||||
|
(current as Record<string, unknown>)[last] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unsetAtPath(root: Record<string, unknown>, path: PathSegment[]): boolean {
|
||||||
|
let current: unknown = root;
|
||||||
|
for (let i = 0; i < path.length - 1; i += 1) {
|
||||||
|
const segment = path[i];
|
||||||
|
if (!current || typeof current !== "object") return false;
|
||||||
|
if (Array.isArray(current)) {
|
||||||
|
if (!isIndexSegment(segment)) return false;
|
||||||
|
const index = Number.parseInt(segment, 10);
|
||||||
|
if (!Number.isFinite(index) || index < 0 || index >= current.length) return false;
|
||||||
|
current = current[index];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const record = current as Record<string, unknown>;
|
||||||
|
if (!(segment in record)) return false;
|
||||||
|
current = record[segment];
|
||||||
|
}
|
||||||
|
|
||||||
|
const last = path[path.length - 1];
|
||||||
|
if (Array.isArray(current)) {
|
||||||
|
if (!isIndexSegment(last)) return false;
|
||||||
|
const index = Number.parseInt(last, 10);
|
||||||
|
if (!Number.isFinite(index) || index < 0 || index >= current.length) return false;
|
||||||
|
current.splice(index, 1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!current || typeof current !== "object") return false;
|
||||||
|
const record = current as Record<string, unknown>;
|
||||||
|
if (!(last in record)) return false;
|
||||||
|
delete record[last];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadValidConfig() {
|
||||||
|
const snapshot = await readConfigFileSnapshot();
|
||||||
|
if (snapshot.valid) return snapshot;
|
||||||
|
defaultRuntime.error(`Config invalid at ${snapshot.path}.`);
|
||||||
|
for (const issue of snapshot.issues) {
|
||||||
|
defaultRuntime.error(`- ${issue.path || "<root>"}: ${issue.message}`);
|
||||||
|
}
|
||||||
|
defaultRuntime.error("Run `clawdbot doctor` to repair, then retry.");
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerConfigCli(program: Command) {
|
||||||
|
const cmd = program
|
||||||
|
.command("config")
|
||||||
|
.description("Config helpers (get/set/unset). Run without subcommand for the wizard.")
|
||||||
|
.addHelpText(
|
||||||
|
"after",
|
||||||
|
() =>
|
||||||
|
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/config", "docs.clawd.bot/cli/config")}\n`,
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
"--section <section>",
|
||||||
|
"Configure wizard sections (repeatable). Use with no subcommand.",
|
||||||
|
(value: string, previous: string[]) => [...previous, value],
|
||||||
|
[] as string[],
|
||||||
|
)
|
||||||
|
.action(async (opts) => {
|
||||||
|
const { CONFIGURE_WIZARD_SECTIONS, configureCommand, configureCommandWithSections } =
|
||||||
|
await import("../commands/configure.js");
|
||||||
|
const sections: string[] = Array.isArray(opts.section)
|
||||||
|
? opts.section
|
||||||
|
.map((value: unknown) => (typeof value === "string" ? value.trim() : ""))
|
||||||
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
if (sections.length === 0) {
|
||||||
|
await configureCommand(defaultRuntime);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalid = sections.filter((s) => !CONFIGURE_WIZARD_SECTIONS.includes(s as never));
|
||||||
|
if (invalid.length > 0) {
|
||||||
|
defaultRuntime.error(
|
||||||
|
`Invalid --section: ${invalid.join(", ")}. Expected one of: ${CONFIGURE_WIZARD_SECTIONS.join(", ")}.`,
|
||||||
|
);
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await configureCommandWithSections(sections as never, defaultRuntime);
|
||||||
|
});
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command("get")
|
||||||
|
.description("Get a config value by dot path")
|
||||||
|
.argument("<path>", "Config path (dot or bracket notation)")
|
||||||
|
.option("--json", "Output JSON", false)
|
||||||
|
.action(async (path: string, opts) => {
|
||||||
|
try {
|
||||||
|
const parsedPath = parsePath(path);
|
||||||
|
if (parsedPath.length === 0) {
|
||||||
|
throw new Error("Path is empty.");
|
||||||
|
}
|
||||||
|
const snapshot = await loadValidConfig();
|
||||||
|
const res = getAtPath(snapshot.config, parsedPath);
|
||||||
|
if (!res.found) {
|
||||||
|
defaultRuntime.error(danger(`Config path not found: ${path}`));
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (opts.json) {
|
||||||
|
defaultRuntime.log(JSON.stringify(res.value ?? null, null, 2));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof res.value === "string" || typeof res.value === "number" || typeof res.value === "boolean") {
|
||||||
|
defaultRuntime.log(String(res.value));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
defaultRuntime.log(JSON.stringify(res.value ?? null, null, 2));
|
||||||
|
} catch (err) {
|
||||||
|
defaultRuntime.error(danger(String(err)));
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command("set")
|
||||||
|
.description("Set a config value by dot path")
|
||||||
|
.argument("<path>", "Config path (dot or bracket notation)")
|
||||||
|
.argument("<value>", "Value (JSON5 or raw string)")
|
||||||
|
.option("--json", "Parse value as JSON5 (required)", false)
|
||||||
|
.action(async (path: string, value: string, opts) => {
|
||||||
|
try {
|
||||||
|
const parsedPath = parsePath(path);
|
||||||
|
if (parsedPath.length === 0) throw new Error("Path is empty.");
|
||||||
|
const parsedValue = parseValue(value, opts);
|
||||||
|
const snapshot = await loadValidConfig();
|
||||||
|
const next = snapshot.config as Record<string, unknown>;
|
||||||
|
setAtPath(next, parsedPath, parsedValue);
|
||||||
|
await writeConfigFile(next);
|
||||||
|
defaultRuntime.log(info(`Updated ${path}. Restart the gateway to apply.`));
|
||||||
|
} catch (err) {
|
||||||
|
defaultRuntime.error(danger(String(err)));
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
cmd
|
||||||
|
.command("unset")
|
||||||
|
.description("Remove a config value by dot path")
|
||||||
|
.argument("<path>", "Config path (dot or bracket notation)")
|
||||||
|
.action(async (path: string) => {
|
||||||
|
try {
|
||||||
|
const parsedPath = parsePath(path);
|
||||||
|
if (parsedPath.length === 0) throw new Error("Path is empty.");
|
||||||
|
const snapshot = await loadValidConfig();
|
||||||
|
const next = snapshot.config as Record<string, unknown>;
|
||||||
|
const removed = unsetAtPath(next, parsedPath);
|
||||||
|
if (!removed) {
|
||||||
|
defaultRuntime.error(danger(`Config path not found: ${path}`));
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await writeConfigFile(next);
|
||||||
|
defaultRuntime.log(info(`Removed ${path}. Restart the gateway to apply.`));
|
||||||
|
} catch (err) {
|
||||||
|
defaultRuntime.error(danger(String(err)));
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -53,13 +53,6 @@ export function registerGatewayCli(program: Command) {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Back-compat: legacy launchd plists used gateway-daemon; keep hidden alias.
|
|
||||||
addGatewayRunCommand(
|
|
||||||
program
|
|
||||||
.command("gateway-daemon", { hidden: true })
|
|
||||||
.description("Run the WebSocket Gateway as a long-lived daemon"),
|
|
||||||
);
|
|
||||||
|
|
||||||
gatewayCallOpts(
|
gatewayCallOpts(
|
||||||
gateway
|
gateway
|
||||||
.command("call")
|
.command("call")
|
||||||
|
|||||||
+156
-3
@@ -1,10 +1,17 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
|
import fsp from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
|
|
||||||
import { loadConfig, writeConfigFile } from "../config/config.js";
|
import { loadConfig, writeConfigFile } from "../config/config.js";
|
||||||
import { installPluginFromArchive, installPluginFromNpmSpec } from "../plugins/install.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
|
import {
|
||||||
|
installPluginFromArchive,
|
||||||
|
installPluginFromNpmSpec,
|
||||||
|
resolvePluginInstallDir,
|
||||||
|
} from "../plugins/install.js";
|
||||||
|
import { recordPluginInstall } from "../plugins/installs.js";
|
||||||
import type { PluginRecord } from "../plugins/registry.js";
|
import type { PluginRecord } from "../plugins/registry.js";
|
||||||
import { buildPluginStatusReport } from "../plugins/status.js";
|
import { buildPluginStatusReport } from "../plugins/status.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
@@ -22,6 +29,11 @@ export type PluginInfoOptions = {
|
|||||||
json?: boolean;
|
json?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PluginUpdateOptions = {
|
||||||
|
all?: boolean;
|
||||||
|
dryRun?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
function formatPluginLine(plugin: PluginRecord, verbose = false): string {
|
function formatPluginLine(plugin: PluginRecord, verbose = false): string {
|
||||||
const status =
|
const status =
|
||||||
plugin.status === "loaded"
|
plugin.status === "loaded"
|
||||||
@@ -56,6 +68,16 @@ function formatPluginLine(plugin: PluginRecord, verbose = false): string {
|
|||||||
return parts.join("\n");
|
return parts.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readInstalledPackageVersion(dir: string): Promise<string | undefined> {
|
||||||
|
try {
|
||||||
|
const raw = await fsp.readFile(path.join(dir, "package.json"), "utf-8");
|
||||||
|
const parsed = JSON.parse(raw) as { version?: unknown };
|
||||||
|
return typeof parsed.version === "string" ? parsed.version : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function registerPluginsCli(program: Command) {
|
export function registerPluginsCli(program: Command) {
|
||||||
const plugins = program
|
const plugins = program
|
||||||
.command("plugins")
|
.command("plugins")
|
||||||
@@ -118,6 +140,8 @@ export function registerPluginsCli(program: Command) {
|
|||||||
defaultRuntime.error(`Plugin not found: ${id}`);
|
defaultRuntime.error(`Plugin not found: ${id}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const install = cfg.plugins?.installs?.[plugin.id];
|
||||||
|
|
||||||
if (opts.json) {
|
if (opts.json) {
|
||||||
defaultRuntime.log(JSON.stringify(plugin, null, 2));
|
defaultRuntime.log(JSON.stringify(plugin, null, 2));
|
||||||
@@ -151,6 +175,15 @@ export function registerPluginsCli(program: Command) {
|
|||||||
lines.push(`Services: ${plugin.services.join(", ")}`);
|
lines.push(`Services: ${plugin.services.join(", ")}`);
|
||||||
}
|
}
|
||||||
if (plugin.error) lines.push(chalk.red(`Error: ${plugin.error}`));
|
if (plugin.error) lines.push(chalk.red(`Error: ${plugin.error}`));
|
||||||
|
if (install) {
|
||||||
|
lines.push("");
|
||||||
|
lines.push(`Install: ${install.source}`);
|
||||||
|
if (install.spec) lines.push(`Spec: ${install.spec}`);
|
||||||
|
if (install.sourcePath) lines.push(`Source path: ${install.sourcePath}`);
|
||||||
|
if (install.installPath) lines.push(`Install path: ${install.installPath}`);
|
||||||
|
if (install.version) lines.push(`Recorded version: ${install.version}`);
|
||||||
|
if (install.installedAt) lines.push(`Installed at: ${install.installedAt}`);
|
||||||
|
}
|
||||||
defaultRuntime.log(lines.join("\n"));
|
defaultRuntime.log(lines.join("\n"));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -223,7 +256,7 @@ export function registerPluginsCli(program: Command) {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const next = {
|
let next: ClawdbotConfig = {
|
||||||
...cfg,
|
...cfg,
|
||||||
plugins: {
|
plugins: {
|
||||||
...cfg.plugins,
|
...cfg.plugins,
|
||||||
@@ -236,6 +269,13 @@ export function registerPluginsCli(program: Command) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
next = recordPluginInstall(next, {
|
||||||
|
pluginId: result.pluginId,
|
||||||
|
source: "archive",
|
||||||
|
sourcePath: resolved,
|
||||||
|
installPath: result.targetDir,
|
||||||
|
version: result.version,
|
||||||
|
});
|
||||||
await writeConfigFile(next);
|
await writeConfigFile(next);
|
||||||
defaultRuntime.log(`Installed plugin: ${result.pluginId}`);
|
defaultRuntime.log(`Installed plugin: ${result.pluginId}`);
|
||||||
defaultRuntime.log(`Restart the gateway to load plugins.`);
|
defaultRuntime.log(`Restart the gateway to load plugins.`);
|
||||||
@@ -287,7 +327,7 @@ export function registerPluginsCli(program: Command) {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const next = {
|
let next: ClawdbotConfig = {
|
||||||
...cfg,
|
...cfg,
|
||||||
plugins: {
|
plugins: {
|
||||||
...cfg.plugins,
|
...cfg.plugins,
|
||||||
@@ -300,11 +340,124 @@ export function registerPluginsCli(program: Command) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
next = recordPluginInstall(next, {
|
||||||
|
pluginId: result.pluginId,
|
||||||
|
source: "npm",
|
||||||
|
spec: raw,
|
||||||
|
installPath: result.targetDir,
|
||||||
|
version: result.version,
|
||||||
|
});
|
||||||
await writeConfigFile(next);
|
await writeConfigFile(next);
|
||||||
defaultRuntime.log(`Installed plugin: ${result.pluginId}`);
|
defaultRuntime.log(`Installed plugin: ${result.pluginId}`);
|
||||||
defaultRuntime.log(`Restart the gateway to load plugins.`);
|
defaultRuntime.log(`Restart the gateway to load plugins.`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
plugins
|
||||||
|
.command("update")
|
||||||
|
.description("Update installed plugins (npm installs only)")
|
||||||
|
.argument("[id]", "Plugin id (omit with --all)")
|
||||||
|
.option("--all", "Update all tracked plugins", false)
|
||||||
|
.option("--dry-run", "Show what would change without writing", false)
|
||||||
|
.action(async (id: string | undefined, opts: PluginUpdateOptions) => {
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const installs = cfg.plugins?.installs ?? {};
|
||||||
|
const targets = opts.all ? Object.keys(installs) : id ? [id] : [];
|
||||||
|
|
||||||
|
if (targets.length === 0) {
|
||||||
|
defaultRuntime.error("Provide a plugin id or use --all.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextCfg = cfg;
|
||||||
|
let updatedCount = 0;
|
||||||
|
|
||||||
|
for (const pluginId of targets) {
|
||||||
|
const record = installs[pluginId];
|
||||||
|
if (!record) {
|
||||||
|
defaultRuntime.log(chalk.yellow(`No install record for "${pluginId}".`));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (record.source !== "npm") {
|
||||||
|
defaultRuntime.log(
|
||||||
|
chalk.yellow(`Skipping "${pluginId}" (source: ${record.source}).`),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!record.spec) {
|
||||||
|
defaultRuntime.log(chalk.yellow(`Skipping "${pluginId}" (missing npm spec).`));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const installPath = record.installPath ?? resolvePluginInstallDir(pluginId);
|
||||||
|
const currentVersion = await readInstalledPackageVersion(installPath);
|
||||||
|
|
||||||
|
if (opts.dryRun) {
|
||||||
|
const probe = await installPluginFromNpmSpec({
|
||||||
|
spec: record.spec,
|
||||||
|
mode: "update",
|
||||||
|
dryRun: true,
|
||||||
|
expectedPluginId: pluginId,
|
||||||
|
logger: {
|
||||||
|
info: (msg) => defaultRuntime.log(msg),
|
||||||
|
warn: (msg) => defaultRuntime.log(chalk.yellow(msg)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!probe.ok) {
|
||||||
|
defaultRuntime.log(chalk.red(`Failed to check ${pluginId}: ${probe.error}`));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextVersion = probe.version ?? "unknown";
|
||||||
|
const currentLabel = currentVersion ?? "unknown";
|
||||||
|
if (currentVersion && probe.version && currentVersion === probe.version) {
|
||||||
|
defaultRuntime.log(`${pluginId} is up to date (${currentLabel}).`);
|
||||||
|
} else {
|
||||||
|
defaultRuntime.log(
|
||||||
|
`Would update ${pluginId}: ${currentLabel} → ${nextVersion}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await installPluginFromNpmSpec({
|
||||||
|
spec: record.spec,
|
||||||
|
mode: "update",
|
||||||
|
expectedPluginId: pluginId,
|
||||||
|
logger: {
|
||||||
|
info: (msg) => defaultRuntime.log(msg),
|
||||||
|
warn: (msg) => defaultRuntime.log(chalk.yellow(msg)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!result.ok) {
|
||||||
|
defaultRuntime.log(chalk.red(`Failed to update ${pluginId}: ${result.error}`));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextVersion = result.version ?? (await readInstalledPackageVersion(result.targetDir));
|
||||||
|
nextCfg = recordPluginInstall(nextCfg, {
|
||||||
|
pluginId,
|
||||||
|
source: "npm",
|
||||||
|
spec: record.spec,
|
||||||
|
installPath: result.targetDir,
|
||||||
|
version: nextVersion,
|
||||||
|
});
|
||||||
|
updatedCount += 1;
|
||||||
|
|
||||||
|
const currentLabel = currentVersion ?? "unknown";
|
||||||
|
const nextLabel = nextVersion ?? "unknown";
|
||||||
|
if (currentVersion && nextVersion && currentVersion === nextVersion) {
|
||||||
|
defaultRuntime.log(`${pluginId} already at ${currentLabel}.`);
|
||||||
|
} else {
|
||||||
|
defaultRuntime.log(`Updated ${pluginId}: ${currentLabel} → ${nextLabel}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updatedCount > 0) {
|
||||||
|
await writeConfigFile(nextCfg);
|
||||||
|
defaultRuntime.log("Restart the gateway to load plugins.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
plugins
|
plugins
|
||||||
.command("doctor")
|
.command("doctor")
|
||||||
.description("Report plugin load issues")
|
.description("Report plugin load issues")
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import { registerBrowserCli } from "../browser-cli.js";
|
import { registerBrowserCli } from "../browser-cli.js";
|
||||||
|
import { registerConfigCli } from "../config-cli.js";
|
||||||
import { createProgramContext } from "./context.js";
|
import { createProgramContext } from "./context.js";
|
||||||
import { configureProgramHelp } from "./help.js";
|
import { configureProgramHelp } from "./help.js";
|
||||||
import { registerPreActionHooks } from "./preaction.js";
|
import { registerPreActionHooks } from "./preaction.js";
|
||||||
@@ -22,6 +23,7 @@ export function buildProgram() {
|
|||||||
registerSetupCommand(program);
|
registerSetupCommand(program);
|
||||||
registerOnboardCommand(program);
|
registerOnboardCommand(program);
|
||||||
registerConfigureCommand(program);
|
registerConfigureCommand(program);
|
||||||
|
registerConfigCli(program);
|
||||||
registerMaintenanceCommands(program);
|
registerMaintenanceCommands(program);
|
||||||
registerMessageCommands(program, ctx);
|
registerMessageCommands(program, ctx);
|
||||||
registerAgentCommands(program, {
|
registerAgentCommands(program, {
|
||||||
|
|||||||
@@ -9,54 +9,45 @@ import { formatDocsLink } from "../../terminal/links.js";
|
|||||||
import { theme } from "../../terminal/theme.js";
|
import { theme } from "../../terminal/theme.js";
|
||||||
|
|
||||||
export function registerConfigureCommand(program: Command) {
|
export function registerConfigureCommand(program: Command) {
|
||||||
const register = (name: "configure" | "config") => {
|
program
|
||||||
program
|
.command("configure")
|
||||||
.command(name)
|
.description("Interactive prompt to set up credentials, devices, and agent defaults")
|
||||||
.description(
|
.addHelpText(
|
||||||
name === "config"
|
"after",
|
||||||
? "Alias for `clawdbot configure`"
|
() =>
|
||||||
: "Interactive prompt to set up credentials, devices, and agent defaults",
|
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/configure", "docs.clawd.bot/cli/configure")}\n`,
|
||||||
)
|
)
|
||||||
.addHelpText(
|
.option(
|
||||||
"after",
|
"--section <section>",
|
||||||
() =>
|
`Configuration sections (repeatable). Options: ${CONFIGURE_WIZARD_SECTIONS.join(", ")}`,
|
||||||
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/configure", "docs.clawd.bot/cli/configure")}\n`,
|
(value: string, previous: string[]) => [...previous, value],
|
||||||
)
|
[] as string[],
|
||||||
.option(
|
)
|
||||||
"--section <section>",
|
.action(async (opts) => {
|
||||||
`Configuration sections (repeatable). Options: ${CONFIGURE_WIZARD_SECTIONS.join(", ")}`,
|
try {
|
||||||
(value: string, previous: string[]) => [...previous, value],
|
const sections: string[] = Array.isArray(opts.section)
|
||||||
[] as string[],
|
? opts.section
|
||||||
)
|
.map((value: unknown) => (typeof value === "string" ? value.trim() : ""))
|
||||||
.action(async (opts) => {
|
.filter(Boolean)
|
||||||
try {
|
: [];
|
||||||
const sections: string[] = Array.isArray(opts.section)
|
if (sections.length === 0) {
|
||||||
? opts.section
|
await configureCommand(defaultRuntime);
|
||||||
.map((value: unknown) => (typeof value === "string" ? value.trim() : ""))
|
return;
|
||||||
.filter(Boolean)
|
|
||||||
: [];
|
|
||||||
if (sections.length === 0) {
|
|
||||||
await configureCommand(defaultRuntime);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const invalid = sections.filter((s) => !CONFIGURE_WIZARD_SECTIONS.includes(s as never));
|
|
||||||
if (invalid.length > 0) {
|
|
||||||
defaultRuntime.error(
|
|
||||||
`Invalid --section: ${invalid.join(", ")}. Expected one of: ${CONFIGURE_WIZARD_SECTIONS.join(", ")}.`,
|
|
||||||
);
|
|
||||||
defaultRuntime.exit(1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await configureCommandWithSections(sections as never, defaultRuntime);
|
|
||||||
} catch (err) {
|
|
||||||
defaultRuntime.error(String(err));
|
|
||||||
defaultRuntime.exit(1);
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
register("configure");
|
const invalid = sections.filter((s) => !CONFIGURE_WIZARD_SECTIONS.includes(s as never));
|
||||||
register("config");
|
if (invalid.length > 0) {
|
||||||
|
defaultRuntime.error(
|
||||||
|
`Invalid --section: ${invalid.join(", ")}. Expected one of: ${CONFIGURE_WIZARD_SECTIONS.join(", ")}.`,
|
||||||
|
);
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await configureCommandWithSections(sections as never, defaultRuntime);
|
||||||
|
} catch (err) {
|
||||||
|
defaultRuntime.error(String(err));
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -372,7 +372,7 @@ describe("doctor command", () => {
|
|||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
}, 10_000);
|
}, 20_000);
|
||||||
|
|
||||||
it("warns when extra workspace directories exist", async () => {
|
it("warns when extra workspace directories exist", async () => {
|
||||||
readConfigFileSnapshot.mockResolvedValue({
|
readConfigFileSnapshot.mockResolvedValue({
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ describe("ensureOnboardingPluginInstalled", () => {
|
|||||||
expect(result.installed).toBe(true);
|
expect(result.installed).toBe(true);
|
||||||
expect(result.cfg.plugins?.entries?.zalo?.enabled).toBe(true);
|
expect(result.cfg.plugins?.entries?.zalo?.enabled).toBe(true);
|
||||||
expect(result.cfg.plugins?.allow).toContain("zalo");
|
expect(result.cfg.plugins?.allow).toContain("zalo");
|
||||||
|
expect(result.cfg.plugins?.installs?.zalo?.source).toBe("npm");
|
||||||
|
expect(result.cfg.plugins?.installs?.zalo?.spec).toBe("@clawdbot/zalo");
|
||||||
|
expect(result.cfg.plugins?.installs?.zalo?.installPath).toBe("/tmp/zalo");
|
||||||
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
|
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ spec: "@clawdbot/zalo" }),
|
expect.objectContaining({ spec: "@clawdbot/zalo" }),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/ag
|
|||||||
import type { ChannelPluginCatalogEntry } from "../../channels/plugins/catalog.js";
|
import type { ChannelPluginCatalogEntry } from "../../channels/plugins/catalog.js";
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import { createSubsystemLogger } from "../../logging.js";
|
import { createSubsystemLogger } from "../../logging.js";
|
||||||
|
import { recordPluginInstall } from "../../plugins/installs.js";
|
||||||
import { loadClawdbotPlugins } from "../../plugins/loader.js";
|
import { loadClawdbotPlugins } from "../../plugins/loader.js";
|
||||||
import { installPluginFromNpmSpec } from "../../plugins/install.js";
|
import { installPluginFromNpmSpec } from "../../plugins/install.js";
|
||||||
import type { RuntimeEnv } from "../../runtime.js";
|
import type { RuntimeEnv } from "../../runtime.js";
|
||||||
@@ -158,6 +159,13 @@ export async function ensureOnboardingPluginInstalled(params: {
|
|||||||
|
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
next = ensurePluginEnabled(next, result.pluginId);
|
next = ensurePluginEnabled(next, result.pluginId);
|
||||||
|
next = recordPluginInstall(next, {
|
||||||
|
pluginId: result.pluginId,
|
||||||
|
source: "npm",
|
||||||
|
spec: entry.install.npmSpec,
|
||||||
|
installPath: result.targetDir,
|
||||||
|
version: result.version,
|
||||||
|
});
|
||||||
return { cfg: next, installed: true };
|
return { cfg: next, installed: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -119,10 +119,11 @@ export async function statusAllCommand(
|
|||||||
|
|
||||||
const localFallbackAuth = resolveProbeAuth("local");
|
const localFallbackAuth = resolveProbeAuth("local");
|
||||||
const remoteAuth = resolveProbeAuth("remote");
|
const remoteAuth = resolveProbeAuth("remote");
|
||||||
|
const probeAuth = isRemoteMode && !remoteUrlMissing ? remoteAuth : localFallbackAuth;
|
||||||
|
|
||||||
const gatewayProbe = await probeGateway({
|
const gatewayProbe = await probeGateway({
|
||||||
url: connection.url,
|
url: connection.url,
|
||||||
auth: remoteUrlMissing ? localFallbackAuth : remoteAuth,
|
auth: probeAuth,
|
||||||
timeoutMs: Math.min(5000, opts?.timeoutMs ?? 10_000),
|
timeoutMs: Math.min(5000, opts?.timeoutMs ?? 10_000),
|
||||||
}).catch(() => null);
|
}).catch(() => null);
|
||||||
const gatewayReachable = gatewayProbe?.ok === true;
|
const gatewayReachable = gatewayProbe?.ok === true;
|
||||||
@@ -291,7 +292,7 @@ export async function statusAllCommand(
|
|||||||
? `unreachable (${gatewayProbe.error})`
|
? `unreachable (${gatewayProbe.error})`
|
||||||
: "unreachable";
|
: "unreachable";
|
||||||
const gatewayAuth = gatewayReachable
|
const gatewayAuth = gatewayReachable
|
||||||
? ` · auth ${formatGatewayAuthUsed(remoteUrlMissing ? localFallbackAuth : remoteAuth)}`
|
? ` · auth ${formatGatewayAuthUsed(probeAuth)}`
|
||||||
: "";
|
: "";
|
||||||
const gatewaySelfLine =
|
const gatewaySelfLine =
|
||||||
gatewaySelf?.host || gatewaySelf?.ip || gatewaySelf?.version || gatewaySelf?.platform
|
gatewaySelf?.host || gatewaySelf?.ip || gatewaySelf?.version || gatewaySelf?.platform
|
||||||
|
|||||||
@@ -28,13 +28,18 @@ describe("config discord", () => {
|
|||||||
dm: {
|
dm: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
allowFrom: ["steipete"],
|
allowFrom: ["steipete"],
|
||||||
groupEnabled: true,
|
groupEnabled: true,
|
||||||
groupChannels: ["clawd-dm"],
|
groupChannels: ["clawd-dm"],
|
||||||
},
|
},
|
||||||
guilds: {
|
actions: {
|
||||||
"123": {
|
emojiUploads: true,
|
||||||
slug: "friends-of-clawd",
|
stickerUploads: false,
|
||||||
requireMention: false,
|
channels: true,
|
||||||
|
},
|
||||||
|
guilds: {
|
||||||
|
"123": {
|
||||||
|
slug: "friends-of-clawd",
|
||||||
|
requireMention: false,
|
||||||
users: ["steipete"],
|
users: ["steipete"],
|
||||||
channels: {
|
channels: {
|
||||||
general: { allow: true },
|
general: { allow: true },
|
||||||
@@ -57,6 +62,9 @@ describe("config discord", () => {
|
|||||||
expect(cfg.channels?.discord?.enabled).toBe(true);
|
expect(cfg.channels?.discord?.enabled).toBe(true);
|
||||||
expect(cfg.channels?.discord?.dm?.groupEnabled).toBe(true);
|
expect(cfg.channels?.discord?.dm?.groupEnabled).toBe(true);
|
||||||
expect(cfg.channels?.discord?.dm?.groupChannels).toEqual(["clawd-dm"]);
|
expect(cfg.channels?.discord?.dm?.groupChannels).toEqual(["clawd-dm"]);
|
||||||
|
expect(cfg.channels?.discord?.actions?.emojiUploads).toBe(true);
|
||||||
|
expect(cfg.channels?.discord?.actions?.stickerUploads).toBe(false);
|
||||||
|
expect(cfg.channels?.discord?.actions?.channels).toBe(true);
|
||||||
expect(cfg.channels?.discord?.guilds?.["123"]?.slug).toBe("friends-of-clawd");
|
expect(cfg.channels?.discord?.guilds?.["123"]?.slug).toBe("friends-of-clawd");
|
||||||
expect(cfg.channels?.discord?.guilds?.["123"]?.channels?.general?.allow).toBe(true);
|
expect(cfg.channels?.discord?.guilds?.["123"]?.channels?.general?.allow).toBe(true);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -220,6 +220,13 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
"plugins.entries": "Plugin Entries",
|
"plugins.entries": "Plugin Entries",
|
||||||
"plugins.entries.*.enabled": "Plugin Enabled",
|
"plugins.entries.*.enabled": "Plugin Enabled",
|
||||||
"plugins.entries.*.config": "Plugin Config",
|
"plugins.entries.*.config": "Plugin Config",
|
||||||
|
"plugins.installs": "Plugin Install Records",
|
||||||
|
"plugins.installs.*.source": "Plugin Install Source",
|
||||||
|
"plugins.installs.*.spec": "Plugin Install Spec",
|
||||||
|
"plugins.installs.*.sourcePath": "Plugin Install Source Path",
|
||||||
|
"plugins.installs.*.installPath": "Plugin Install Path",
|
||||||
|
"plugins.installs.*.version": "Plugin Install Version",
|
||||||
|
"plugins.installs.*.installedAt": "Plugin Install Time",
|
||||||
};
|
};
|
||||||
|
|
||||||
const FIELD_HELP: Record<string, string> = {
|
const FIELD_HELP: Record<string, string> = {
|
||||||
@@ -291,6 +298,14 @@ const FIELD_HELP: Record<string, string> = {
|
|||||||
"plugins.entries": "Per-plugin settings keyed by plugin id (enable/disable + config payloads).",
|
"plugins.entries": "Per-plugin settings keyed by plugin id (enable/disable + config payloads).",
|
||||||
"plugins.entries.*.enabled": "Overrides plugin enable/disable for this entry (restart required).",
|
"plugins.entries.*.enabled": "Overrides plugin enable/disable for this entry (restart required).",
|
||||||
"plugins.entries.*.config": "Plugin-defined config payload (schema is provided by the plugin).",
|
"plugins.entries.*.config": "Plugin-defined config payload (schema is provided by the plugin).",
|
||||||
|
"plugins.installs":
|
||||||
|
"CLI-managed install metadata (used by `clawdbot plugins update` to locate install sources).",
|
||||||
|
"plugins.installs.*.source": 'Install source ("npm", "archive", or "path").',
|
||||||
|
"plugins.installs.*.spec": "Original npm spec used for install (if source is npm).",
|
||||||
|
"plugins.installs.*.sourcePath": "Original archive/path used for install (if any).",
|
||||||
|
"plugins.installs.*.installPath": "Resolved install directory (usually ~/.clawdbot/extensions/<id>).",
|
||||||
|
"plugins.installs.*.version": "Version recorded at install time (if available).",
|
||||||
|
"plugins.installs.*.installedAt": "ISO timestamp of last install/update.",
|
||||||
"agents.defaults.model.primary": "Primary model (provider/model).",
|
"agents.defaults.model.primary": "Primary model (provider/model).",
|
||||||
"agents.defaults.model.fallbacks":
|
"agents.defaults.model.fallbacks":
|
||||||
"Ordered fallback models (provider/model). Used when the primary model fails.",
|
"Ordered fallback models (provider/model). Used when the primary model fails.",
|
||||||
|
|||||||
@@ -8,6 +8,15 @@ export type PluginsLoadConfig = {
|
|||||||
paths?: string[];
|
paths?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PluginInstallRecord = {
|
||||||
|
source: "npm" | "archive" | "path";
|
||||||
|
spec?: string;
|
||||||
|
sourcePath?: string;
|
||||||
|
installPath?: string;
|
||||||
|
version?: string;
|
||||||
|
installedAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type PluginsConfig = {
|
export type PluginsConfig = {
|
||||||
/** Enable or disable plugin loading. */
|
/** Enable or disable plugin loading. */
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
@@ -17,4 +26,5 @@ export type PluginsConfig = {
|
|||||||
deny?: string[];
|
deny?: string[];
|
||||||
load?: PluginsLoadConfig;
|
load?: PluginsLoadConfig;
|
||||||
entries?: Record<string, PluginEntryConfig>;
|
entries?: Record<string, PluginEntryConfig>;
|
||||||
|
installs?: Record<string, PluginInstallRecord>;
|
||||||
};
|
};
|
||||||
@@ -154,6 +154,8 @@ export const DiscordAccountSchema = z.object({
|
|||||||
.object({
|
.object({
|
||||||
reactions: z.boolean().optional(),
|
reactions: z.boolean().optional(),
|
||||||
stickers: z.boolean().optional(),
|
stickers: z.boolean().optional(),
|
||||||
|
emojiUploads: z.boolean().optional(),
|
||||||
|
stickerUploads: z.boolean().optional(),
|
||||||
polls: z.boolean().optional(),
|
polls: z.boolean().optional(),
|
||||||
permissions: z.boolean().optional(),
|
permissions: z.boolean().optional(),
|
||||||
messages: z.boolean().optional(),
|
messages: z.boolean().optional(),
|
||||||
@@ -167,6 +169,7 @@ export const DiscordAccountSchema = z.object({
|
|||||||
voiceStatus: z.boolean().optional(),
|
voiceStatus: z.boolean().optional(),
|
||||||
events: z.boolean().optional(),
|
events: z.boolean().optional(),
|
||||||
moderation: z.boolean().optional(),
|
moderation: z.boolean().optional(),
|
||||||
|
channels: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
replyToMode: ReplyToModeSchema.optional(),
|
replyToMode: ReplyToModeSchema.optional(),
|
||||||
|
|||||||
@@ -326,6 +326,21 @@ export const ClawdbotSchema = z
|
|||||||
.passthrough(),
|
.passthrough(),
|
||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
|
installs: z
|
||||||
|
.record(
|
||||||
|
z.string(),
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
source: z.union([z.literal("npm"), z.literal("archive"), z.literal("path")]),
|
||||||
|
spec: z.string().optional(),
|
||||||
|
sourcePath: z.string().optional(),
|
||||||
|
installPath: z.string().optional(),
|
||||||
|
version: z.string().optional(),
|
||||||
|
installedAt: z.string().optional(),
|
||||||
|
})
|
||||||
|
.passthrough(),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -25,14 +25,14 @@ describe("parseSystemdExecStart", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves backslashes in arguments", () => {
|
it("parses path arguments", () => {
|
||||||
const execStart = String.raw`/usr/bin/clawdbot gateway start --path \/tmp\/clawdbot`;
|
const execStart = "/usr/bin/clawdbot gateway start --path /tmp/clawdbot";
|
||||||
expect(parseSystemdExecStart(execStart)).toEqual([
|
expect(parseSystemdExecStart(execStart)).toEqual([
|
||||||
"/usr/bin/clawdbot",
|
"/usr/bin/clawdbot",
|
||||||
"gateway",
|
"gateway",
|
||||||
"start",
|
"start",
|
||||||
"--path",
|
"--path",
|
||||||
"\\/tmp\\/clawdbot",
|
"/tmp/clawdbot",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -172,6 +172,61 @@ describe("installPluginFromArchive", () => {
|
|||||||
expect(second.error).toContain("already exists");
|
expect(second.error).toContain("already exists");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("allows updates when mode is update", async () => {
|
||||||
|
const stateDir = makeTempDir();
|
||||||
|
const workDir = makeTempDir();
|
||||||
|
const pkgDir = path.join(workDir, "package");
|
||||||
|
fs.mkdirSync(path.join(pkgDir, "dist"), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(pkgDir, "package.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
name: "@clawdbot/voice-call",
|
||||||
|
version: "0.0.1",
|
||||||
|
clawdbot: { extensions: ["./dist/index.js"] },
|
||||||
|
}),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
fs.writeFileSync(path.join(pkgDir, "dist", "index.js"), "export {};", "utf-8");
|
||||||
|
|
||||||
|
const archiveV1 = packToArchive({
|
||||||
|
pkgDir,
|
||||||
|
outDir: workDir,
|
||||||
|
outName: "plugin-v1.tgz",
|
||||||
|
});
|
||||||
|
|
||||||
|
const archiveV2 = (() => {
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(pkgDir, "package.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
name: "@clawdbot/voice-call",
|
||||||
|
version: "0.0.2",
|
||||||
|
clawdbot: { extensions: ["./dist/index.js"] },
|
||||||
|
}),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
return packToArchive({
|
||||||
|
pkgDir,
|
||||||
|
outDir: workDir,
|
||||||
|
outName: "plugin-v2.tgz",
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
const result = await withStateDir(stateDir, async () => {
|
||||||
|
const { installPluginFromArchive } = await import("./install.js");
|
||||||
|
const first = await installPluginFromArchive({ archivePath: archiveV1 });
|
||||||
|
const second = await installPluginFromArchive({ archivePath: archiveV2, mode: "update" });
|
||||||
|
return { first, second };
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.first.ok).toBe(true);
|
||||||
|
expect(result.second.ok).toBe(true);
|
||||||
|
if (!result.second.ok) return;
|
||||||
|
const manifest = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(result.second.targetDir, "package.json"), "utf-8"),
|
||||||
|
) as { version?: string };
|
||||||
|
expect(manifest.version).toBe("0.0.2");
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects packages without clawdbot.extensions", async () => {
|
it("rejects packages without clawdbot.extensions", async () => {
|
||||||
const stateDir = makeTempDir();
|
const stateDir = makeTempDir();
|
||||||
const workDir = makeTempDir();
|
const workDir = makeTempDir();
|
||||||
|
|||||||
+64
-2
@@ -12,6 +12,7 @@ type PluginInstallLogger = {
|
|||||||
|
|
||||||
type PackageManifest = {
|
type PackageManifest = {
|
||||||
name?: string;
|
name?: string;
|
||||||
|
version?: string;
|
||||||
dependencies?: Record<string, string>;
|
dependencies?: Record<string, string>;
|
||||||
clawdbot?: { extensions?: string[] };
|
clawdbot?: { extensions?: string[] };
|
||||||
};
|
};
|
||||||
@@ -22,6 +23,7 @@ export type InstallPluginResult =
|
|||||||
pluginId: string;
|
pluginId: string;
|
||||||
targetDir: string;
|
targetDir: string;
|
||||||
manifestName?: string;
|
manifestName?: string;
|
||||||
|
version?: string;
|
||||||
extensions: string[];
|
extensions: string[];
|
||||||
}
|
}
|
||||||
| { ok: false; error: string };
|
| { ok: false; error: string };
|
||||||
@@ -70,6 +72,13 @@ async function resolvePackedPackageDir(extractDir: string): Promise<string> {
|
|||||||
return path.join(extractDir, onlyDir);
|
return path.join(extractDir, onlyDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolvePluginInstallDir(pluginId: string, extensionsDir?: string): string {
|
||||||
|
const extensionsBase = extensionsDir
|
||||||
|
? resolveUserPath(extensionsDir)
|
||||||
|
: path.join(CONFIG_DIR, "extensions");
|
||||||
|
return path.join(extensionsBase, safeDirName(pluginId));
|
||||||
|
}
|
||||||
|
|
||||||
async function ensureClawdbotExtensions(manifest: PackageManifest) {
|
async function ensureClawdbotExtensions(manifest: PackageManifest) {
|
||||||
const extensions = manifest.clawdbot?.extensions;
|
const extensions = manifest.clawdbot?.extensions;
|
||||||
if (!Array.isArray(extensions)) {
|
if (!Array.isArray(extensions)) {
|
||||||
@@ -104,9 +113,14 @@ export async function installPluginFromArchive(params: {
|
|||||||
extensionsDir?: string;
|
extensionsDir?: string;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
logger?: PluginInstallLogger;
|
logger?: PluginInstallLogger;
|
||||||
|
mode?: "install" | "update";
|
||||||
|
dryRun?: boolean;
|
||||||
|
expectedPluginId?: string;
|
||||||
}): Promise<InstallPluginResult> {
|
}): Promise<InstallPluginResult> {
|
||||||
const logger = params.logger ?? defaultLogger;
|
const logger = params.logger ?? defaultLogger;
|
||||||
const timeoutMs = params.timeoutMs ?? 120_000;
|
const timeoutMs = params.timeoutMs ?? 120_000;
|
||||||
|
const mode = params.mode ?? "install";
|
||||||
|
const dryRun = params.dryRun ?? false;
|
||||||
|
|
||||||
const archivePath = resolveUserPath(params.archivePath);
|
const archivePath = resolveUserPath(params.archivePath);
|
||||||
if (!(await fileExists(archivePath))) {
|
if (!(await fileExists(archivePath))) {
|
||||||
@@ -157,17 +171,47 @@ export async function installPluginFromArchive(params: {
|
|||||||
|
|
||||||
const pkgName = typeof manifest.name === "string" ? manifest.name : "";
|
const pkgName = typeof manifest.name === "string" ? manifest.name : "";
|
||||||
const pluginId = pkgName ? unscopedPackageName(pkgName) : "plugin";
|
const pluginId = pkgName ? unscopedPackageName(pkgName) : "plugin";
|
||||||
|
if (params.expectedPluginId && params.expectedPluginId !== pluginId) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `plugin id mismatch: expected ${params.expectedPluginId}, got ${pluginId}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
const targetDir = path.join(extensionsDir, safeDirName(pluginId));
|
const targetDir = path.join(extensionsDir, safeDirName(pluginId));
|
||||||
|
|
||||||
if (await fileExists(targetDir)) {
|
if (mode === "install" && (await fileExists(targetDir))) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: `plugin already exists: ${targetDir} (delete it first)`,
|
error: `plugin already exists: ${targetDir} (delete it first)`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
pluginId,
|
||||||
|
targetDir,
|
||||||
|
manifestName: pkgName || undefined,
|
||||||
|
version: typeof manifest.version === "string" ? manifest.version : undefined,
|
||||||
|
extensions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
logger.info?.(`Installing to ${targetDir}…`);
|
logger.info?.(`Installing to ${targetDir}…`);
|
||||||
await fs.cp(packageDir, targetDir, { recursive: true });
|
let backupDir: string | null = null;
|
||||||
|
if (mode === "update" && (await fileExists(targetDir))) {
|
||||||
|
backupDir = `${targetDir}.backup-${Date.now()}`;
|
||||||
|
await fs.rename(targetDir, backupDir);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await fs.cp(packageDir, targetDir, { recursive: true });
|
||||||
|
} catch (err) {
|
||||||
|
if (backupDir) {
|
||||||
|
await fs.rm(targetDir, { recursive: true, force: true }).catch(() => undefined);
|
||||||
|
await fs.rename(backupDir, targetDir).catch(() => undefined);
|
||||||
|
}
|
||||||
|
return { ok: false, error: `failed to copy plugin: ${String(err)}` };
|
||||||
|
}
|
||||||
|
|
||||||
for (const entry of extensions) {
|
for (const entry of extensions) {
|
||||||
const resolvedEntry = path.resolve(targetDir, entry);
|
const resolvedEntry = path.resolve(targetDir, entry);
|
||||||
@@ -185,6 +229,10 @@ export async function installPluginFromArchive(params: {
|
|||||||
cwd: targetDir,
|
cwd: targetDir,
|
||||||
});
|
});
|
||||||
if (npmRes.code !== 0) {
|
if (npmRes.code !== 0) {
|
||||||
|
if (backupDir) {
|
||||||
|
await fs.rm(targetDir, { recursive: true, force: true }).catch(() => undefined);
|
||||||
|
await fs.rename(backupDir, targetDir).catch(() => undefined);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: `npm install failed: ${npmRes.stderr.trim() || npmRes.stdout.trim()}`,
|
error: `npm install failed: ${npmRes.stderr.trim() || npmRes.stdout.trim()}`,
|
||||||
@@ -192,11 +240,16 @@ export async function installPluginFromArchive(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (backupDir) {
|
||||||
|
await fs.rm(backupDir, { recursive: true, force: true }).catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
pluginId,
|
pluginId,
|
||||||
targetDir,
|
targetDir,
|
||||||
manifestName: pkgName || undefined,
|
manifestName: pkgName || undefined,
|
||||||
|
version: typeof manifest.version === "string" ? manifest.version : undefined,
|
||||||
extensions,
|
extensions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -206,9 +259,15 @@ export async function installPluginFromNpmSpec(params: {
|
|||||||
extensionsDir?: string;
|
extensionsDir?: string;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
logger?: PluginInstallLogger;
|
logger?: PluginInstallLogger;
|
||||||
|
mode?: "install" | "update";
|
||||||
|
dryRun?: boolean;
|
||||||
|
expectedPluginId?: string;
|
||||||
}): Promise<InstallPluginResult> {
|
}): Promise<InstallPluginResult> {
|
||||||
const logger = params.logger ?? defaultLogger;
|
const logger = params.logger ?? defaultLogger;
|
||||||
const timeoutMs = params.timeoutMs ?? 120_000;
|
const timeoutMs = params.timeoutMs ?? 120_000;
|
||||||
|
const mode = params.mode ?? "install";
|
||||||
|
const dryRun = params.dryRun ?? false;
|
||||||
|
const expectedPluginId = params.expectedPluginId;
|
||||||
const spec = params.spec.trim();
|
const spec = params.spec.trim();
|
||||||
if (!spec) return { ok: false, error: "missing npm spec" };
|
if (!spec) return { ok: false, error: "missing npm spec" };
|
||||||
|
|
||||||
@@ -241,5 +300,8 @@ export async function installPluginFromNpmSpec(params: {
|
|||||||
extensionsDir: params.extensionsDir,
|
extensionsDir: params.extensionsDir,
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
logger,
|
logger,
|
||||||
|
mode,
|
||||||
|
dryRun,
|
||||||
|
expectedPluginId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
|
import type { PluginInstallRecord } from "../config/types.plugins.js";
|
||||||
|
|
||||||
|
export type PluginInstallUpdate = PluginInstallRecord & { pluginId: string };
|
||||||
|
|
||||||
|
export function recordPluginInstall(cfg: ClawdbotConfig, update: PluginInstallUpdate): ClawdbotConfig {
|
||||||
|
const { pluginId, ...record } = update;
|
||||||
|
const installs = {
|
||||||
|
...cfg.plugins?.installs,
|
||||||
|
[pluginId]: {
|
||||||
|
...cfg.plugins?.installs?.[pluginId],
|
||||||
|
...record,
|
||||||
|
installedAt: record.installedAt ?? new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...cfg,
|
||||||
|
plugins: {
|
||||||
|
...cfg.plugins,
|
||||||
|
installs: {
|
||||||
|
...installs,
|
||||||
|
[pluginId]: installs[pluginId],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -99,7 +99,7 @@ describe("telegram inbound media", () => {
|
|||||||
async () => {
|
async () => {
|
||||||
const { createTelegramBot } = await import("./bot.js");
|
const { createTelegramBot } = await import("./bot.js");
|
||||||
const replyModule = await import("../auto-reply/reply.js");
|
const replyModule = await import("../auto-reply/reply.js");
|
||||||
const replySpy = replyModule.getReplyFromConfig as unknown as ReturnType<typeof vi.fn>;
|
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
onSpy.mockReset();
|
onSpy.mockReset();
|
||||||
replySpy.mockReset();
|
replySpy.mockReset();
|
||||||
@@ -143,7 +143,7 @@ describe("telegram inbound media", () => {
|
|||||||
async () => {
|
async () => {
|
||||||
const { createTelegramBot } = await import("./bot.js");
|
const { createTelegramBot } = await import("./bot.js");
|
||||||
const replyModule = await import("../auto-reply/reply.js");
|
const replyModule = await import("../auto-reply/reply.js");
|
||||||
const replySpy = replyModule.getReplyFromConfig as unknown as ReturnType<typeof vi.fn>;
|
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
onSpy.mockReset();
|
onSpy.mockReset();
|
||||||
replySpy.mockReset();
|
replySpy.mockReset();
|
||||||
|
|||||||
@@ -3,9 +3,7 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { DisconnectReason } from "@whiskeysockets/baileys";
|
import { DisconnectReason } from "@whiskeysockets/baileys";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
vi.useFakeTimers();
|
|
||||||
|
|
||||||
const rmMock = vi.spyOn(fs, "rm");
|
const rmMock = vi.spyOn(fs, "rm");
|
||||||
|
|
||||||
@@ -51,9 +49,13 @@ const { loginWeb } = await import("./login.js");
|
|||||||
|
|
||||||
describe("loginWeb coverage", () => {
|
describe("loginWeb coverage", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
rmMock.mockClear();
|
rmMock.mockClear();
|
||||||
});
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
it("restarts once when WhatsApp requests code 515", async () => {
|
it("restarts once when WhatsApp requests code 515", async () => {
|
||||||
waitForWaConnection
|
waitForWaConnection
|
||||||
|
|||||||
Reference in new issue
Block a user