Compare commits

..
62 changed files with 1894 additions and 756 deletions

No files matched your search

+2
View File
@@ -7,9 +7,11 @@ Docs: https://docs.clawd.bot
### Changes
- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
- Signal: add typing indicators and DM read receipts via signal-cli.
### Fixes
- Doctor: warn when gateway.mode is unset with configure/config guidance.
- Lobster: fix plugin discovery and harden the tool runtime. (#1152) Thanks @vignesh07.
## 2026.1.21
+5 -5
View File
@@ -3,13 +3,13 @@
<channel>
<title>Clawdbot</title>
<item>
<title>2026.1.20</title>
<title>2026.1.21</title>
<pubDate>Wed, 21 Jan 2026 08:18:22 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>7116</sparkle:version>
<sparkle:shortVersionString>2026.1.20</sparkle:shortVersionString>
<sparkle:shortVersionString>2026.1.21</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.20</h2>
<description><![CDATA[<h2>Clawdbot 2026.1.21</h2>
<h3>Changes</h3>
<ul>
<li>Control UI: add copy-as-markdown with error feedback. (#1345) https://docs.clawd.bot/web/control-ui</li>
@@ -190,7 +190,7 @@
Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @NicholaiVogel, @RyanLisse, @ThePickle31, @VACInc, @Whoaa512, @YuriNachos, @aaronveklabs, @abdaraxus, @alauppe, @ameno-, @artuskg, @austinm911, @bradleypriest, @cheeeee, @dougvk, @fogboots, @gnarco, @gumadeiras, @jdrhyne, @joelklabo, @longmaba, @mukhtharcm, @odysseus0, @oscargavin, @rhjoh, @sebslight, @sibbl, @sleontenko, @steipete, @suminhthanh, @thewilloftheshadow, @tyler6204, @vignesh07, @visionik, @ysqander, @zerone0x.
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.20/Clawdbot-2026.1.20.zip" length="12208102" type="application/octet-stream" sparkle:edSignature="hU495Eii8O3qmmUnxYFhXyEGv+qan6KL+GpeuBhPIXf+7B5F/gBh5Oz9cHaqaAPoZ4/3Bo6xgvic0HTkbz6gDw=="/>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.21/Clawdbot-2026.1.21.zip" length="12208102" type="application/octet-stream" sparkle:edSignature="hU495Eii8O3qmmUnxYFhXyEGv+qan6KL+GpeuBhPIXf+7B5F/gBh5Oz9cHaqaAPoZ4/3Bo6xgvic0HTkbz6gDw=="/>
</item>
<item>
<title>2026.1.16-2</title>
@@ -290,4 +290,4 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.15/Clawdbot-2026.1.15.zip" length="12127276" type="application/octet-stream" sparkle:edSignature="o79vwTbtW/d91NQFRVfUDhsv6D4zIw7IkhY0N1iLImMu94BURgLcecA6z7Smy3bMobPwOyzN8yfm6mA/Rt8FCA=="/>
</item>
</channel>
</rss>
</rss>
+2 -2
View File
@@ -21,8 +21,8 @@ android {
applicationId = "com.clawdbot.android"
minSdk = 31
targetSdk = 36
versionCode = 202601200
versionName = "2026.1.20"
versionCode = 202601210
versionName = "2026.1.21"
}
buildTypes {
+2 -2
View File
@@ -19,9 +19,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.1.20</string>
<string>2026.1.21</string>
<key>CFBundleVersion</key>
<string>20260120</string>
<string>20260121</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoadsInWebContent</key>
+2 -2
View File
@@ -17,8 +17,8 @@
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>2026.1.20</string>
<string>2026.1.21</string>
<key>CFBundleVersion</key>
<string>20260120</string>
<string>20260121</string>
</dict>
</plist>
+4 -4
View File
@@ -81,8 +81,8 @@ targets:
properties:
CFBundleDisplayName: Clawdbot
CFBundleIconName: AppIcon
CFBundleShortVersionString: "2026.1.20"
CFBundleVersion: "20260120"
CFBundleShortVersionString: "2026.1.21"
CFBundleVersion: "20260121"
UILaunchScreen: {}
UIApplicationSceneManifest:
UIApplicationSupportsMultipleScenes: false
@@ -130,5 +130,5 @@ targets:
path: Tests/Info.plist
properties:
CFBundleDisplayName: ClawdbotTests
CFBundleShortVersionString: "2026.1.20"
CFBundleVersion: "20260120"
CFBundleShortVersionString: "2026.1.21"
CFBundleVersion: "20260121"
@@ -6,15 +6,20 @@ final class ConnectionModeCoordinator {
static let shared = ConnectionModeCoordinator()
private let logger = Logger(subsystem: "com.clawdbot", category: "connection")
private var lastMode: AppState.ConnectionMode?
/// Apply the requested connection mode by starting/stopping local gateway,
/// managing the control-channel SSH tunnel, and cleaning up chat windows/panels.
func apply(mode: AppState.ConnectionMode, paused: Bool) async {
if let lastMode = self.lastMode, lastMode != mode {
GatewayProcessManager.shared.clearLastFailure()
NodesStore.shared.lastError = nil
}
self.lastMode = mode
switch mode {
case .unconfigured:
if let error = await NodeServiceManager.stop() {
NodesStore.shared.lastError = "Node service stop failed: \(error)"
}
_ = await NodeServiceManager.stop()
NodesStore.shared.lastError = nil
await RemoteTunnelManager.shared.stopAll()
WebChatManager.shared.resetTunnels()
GatewayProcessManager.shared.stop()
@@ -23,9 +28,8 @@ final class ConnectionModeCoordinator {
Task.detached { await PortGuardian.shared.sweep(mode: .unconfigured) }
case .local:
if let error = await NodeServiceManager.stop() {
NodesStore.shared.lastError = "Node service stop failed: \(error)"
}
_ = await NodeServiceManager.stop()
NodesStore.shared.lastError = nil
await RemoteTunnelManager.shared.stopAll()
WebChatManager.shared.resetTunnels()
let shouldStart = GatewayAutostartPolicy.shouldStartGateway(mode: .local, paused: paused)
@@ -56,6 +60,7 @@ final class ConnectionModeCoordinator {
WebChatManager.shared.resetTunnels()
do {
NodesStore.shared.lastError = nil
if let error = await NodeServiceManager.start() {
NodesStore.shared.lastError = "Node service start failed: \(error)"
}
@@ -42,10 +42,20 @@ final class GatewayProcessManager {
private var environmentRefreshTask: Task<Void, Never>?
private var lastEnvironmentRefresh: Date?
private var logRefreshTask: Task<Void, Never>?
#if DEBUG
private var testingConnection: GatewayConnection?
#endif
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway.process")
private let logLimit = 20000 // characters to keep in-memory
private let environmentRefreshMinInterval: TimeInterval = 30
private var connection: GatewayConnection {
#if DEBUG
return self.testingConnection ?? .shared
#else
return .shared
#endif
}
func setActive(_ active: Bool) {
// Remote mode should never spawn a local gateway; treat as stopped.
@@ -126,6 +136,10 @@ final class GatewayProcessManager {
}
}
func clearLastFailure() {
self.lastFailureReason = nil
}
func refreshEnvironmentStatus(force: Bool = false) {
let now = Date()
if !force {
@@ -178,7 +192,7 @@ final class GatewayProcessManager {
let hasListener = instance != nil
let attemptAttach = {
try await GatewayConnection.shared.requestRaw(method: .health, timeoutMs: 2000)
try await self.connection.requestRaw(method: .health, timeoutMs: 2000)
}
for attempt in 0..<(hasListener ? 3 : 1) {
@@ -187,6 +201,7 @@ final class GatewayProcessManager {
let snap = decodeHealthSnapshot(from: data)
let details = self.describe(details: instanceText, port: port, snap: snap)
self.existingGatewayDetails = details
self.clearLastFailure()
self.status = .attachedExisting(details: details)
self.appendLog("[gateway] using existing instance: \(details)\n")
self.logger.info("gateway using existing instance details=\(details)")
@@ -310,9 +325,10 @@ final class GatewayProcessManager {
while Date() < deadline {
if !self.desiredActive { return }
do {
_ = try await GatewayConnection.shared.requestRaw(method: .health, timeoutMs: 1500)
_ = try await self.connection.requestRaw(method: .health, timeoutMs: 1500)
let instance = await PortGuardian.shared.describe(port: port)
let details = instance.map { "pid \($0.pid)" }
self.clearLastFailure()
self.status = .running(details: details)
self.logger.info("gateway started details=\(details ?? "ok")")
self.refreshControlChannelIfNeeded(reason: "gateway started")
@@ -352,7 +368,8 @@ final class GatewayProcessManager {
while Date() < deadline {
if !self.desiredActive { return false }
do {
_ = try await GatewayConnection.shared.requestRaw(method: .health, timeoutMs: 1500)
_ = try await self.connection.requestRaw(method: .health, timeoutMs: 1500)
self.clearLastFailure()
return true
} catch {
try? await Task.sleep(nanoseconds: 300_000_000)
@@ -385,3 +402,19 @@ final class GatewayProcessManager {
return String(text.suffix(limit))
}
}
#if DEBUG
extension GatewayProcessManager {
func setTestingConnection(_ connection: GatewayConnection?) {
self.testingConnection = connection
}
func setTestingDesiredActive(_ active: Bool) {
self.desiredActive = active
}
func setTestingLastFailureReason(_ reason: String?) {
self.lastFailureReason = reason
}
}
#endif
@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.1.20</string>
<string>2026.1.21</string>
<key>CFBundleVersion</key>
<string>202601200</string>
<string>202601210</string>
<key>CFBundleIconFile</key>
<string>Clawdbot</string>
<key>CFBundleURLTypes</key>
@@ -0,0 +1,146 @@
import Foundation
import os
import Testing
@testable import Clawdbot
@Suite(.serialized)
@MainActor
struct GatewayProcessManagerTests {
private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
private let connectRequestID = OSAllocatedUnfairLock<String?>(initialState: nil)
private let pendingReceiveHandler =
OSAllocatedUnfairLock<(@Sendable (Result<URLSessionWebSocketTask.Message, Error>)
-> Void)?>(initialState: nil)
private let cancelCount = OSAllocatedUnfairLock(initialState: 0)
private let sendCount = OSAllocatedUnfairLock(initialState: 0)
var state: URLSessionTask.State = .suspended
func resume() {
self.state = .running
}
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
_ = (closeCode, reason)
self.state = .canceling
self.cancelCount.withLock { $0 += 1 }
let handler = self.pendingReceiveHandler.withLock { handler in
defer { handler = nil }
return handler
}
handler?(Result<URLSessionWebSocketTask.Message, Error>.failure(URLError(.cancelled)))
}
func send(_ message: URLSessionWebSocketTask.Message) async throws {
let currentSendCount = self.sendCount.withLock { count in
defer { count += 1 }
return count
}
if currentSendCount == 0 {
guard case let .data(data) = message else { return }
if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
(obj["type"] as? String) == "req",
(obj["method"] as? String) == "connect",
let id = obj["id"] as? String
{
self.connectRequestID.withLock { $0 = id }
}
return
}
guard case let .data(data) = message else { return }
guard
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
(obj["type"] as? String) == "req",
let id = obj["id"] as? String
else {
return
}
let response = Self.responseData(id: id)
let handler = self.pendingReceiveHandler.withLock { $0 }
handler?(Result<URLSessionWebSocketTask.Message, Error>.success(.data(response)))
}
func receive() async throws -> URLSessionWebSocketTask.Message {
let id = self.connectRequestID.withLock { $0 } ?? "connect"
return .data(Self.connectOkData(id: id))
}
func receive(
completionHandler: @escaping @Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)
{
self.pendingReceiveHandler.withLock { $0 = completionHandler }
}
private static func connectOkData(id: String) -> Data {
let json = """
{
"type": "res",
"id": "\(id)",
"ok": true,
"payload": {
"type": "hello-ok",
"protocol": 2,
"server": { "version": "test", "connId": "test" },
"features": { "methods": [], "events": [] },
"snapshot": {
"presence": [ { "ts": 1 } ],
"health": {},
"stateVersion": { "presence": 0, "health": 0 },
"uptimeMs": 0
},
"policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 }
}
}
"""
return Data(json.utf8)
}
private static func responseData(id: String) -> Data {
let json = """
{
"type": "res",
"id": "\(id)",
"ok": true,
"payload": { "ok": true }
}
"""
return Data(json.utf8)
}
}
private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable {
private let tasks = OSAllocatedUnfairLock(initialState: [FakeWebSocketTask]())
func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
_ = url
let task = FakeWebSocketTask()
self.tasks.withLock { $0.append(task) }
return WebSocketTaskBox(task: task)
}
}
@Test func clearsLastFailureWhenHealthSucceeds() async {
let session = FakeWebSocketSession()
let url = URL(string: "ws://example.invalid")!
let connection = GatewayConnection(
configProvider: { (url: url, token: nil, password: nil) },
sessionBox: WebSocketSessionBox(session: session))
let manager = GatewayProcessManager.shared
manager.setTestingConnection(connection)
manager.setTestingDesiredActive(true)
manager.setTestingLastFailureReason("health failed")
defer {
manager.setTestingConnection(nil)
manager.setTestingDesiredActive(false)
manager.setTestingLastFailureReason(nil)
}
let ready = await manager.waitForGatewayReady(timeout: 0.5)
#expect(ready)
#expect(manager.lastFailureReason == nil)
}
}
+5
View File
@@ -100,6 +100,11 @@ Groups:
- Use `channels.signal.ignoreAttachments` to skip downloading media.
- Group history context uses `channels.signal.historyLimit` (or `channels.signal.accounts.*.historyLimit`), falling back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
## Typing + read receipts
- **Typing indicators**: Clawdbot sends typing signals via `signal-cli sendTyping` and refreshes them while a reply is running.
- **Read receipts**: when `channels.signal.sendReadReceipts` is true, Clawdbot forwards read receipts for allowed DMs.
- Signal-cli does not expose read receipts for groups.
## Delivery targets (CLI/cron)
- DMs: `signal:+15551234567` (or plain E.164).
- Groups: `signal:group:<groupId>`.
+1
View File
@@ -977,6 +977,7 @@
"plugin",
"plugins/voice-call",
"plugins/zalouser",
"tools/lobster",
"tools/exec",
"tools/web",
"tools/apply-patch",
+7 -7
View File
@@ -30,17 +30,17 @@ Notes:
# From repo root; set release IDs so Sparkle feed is enabled.
# APP_BUILD must be numeric + monotonic for Sparkle compare.
BUNDLE_ID=com.clawdbot.mac \
APP_VERSION=2026.1.20 \
APP_VERSION=2026.1.21 \
APP_BUILD="$(git rev-list --count HEAD)" \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
scripts/package-mac-app.sh
# Zip for distribution (includes resource forks for Sparkle delta support)
ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.20.zip
ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.21.zip
# Optional: also build a styled DMG for humans (drag to /Applications)
scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.20.dmg
scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.21.dmg
# Recommended: build + notarize/staple zip + DMG
# First, create a keychain profile once:
@@ -48,26 +48,26 @@ scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.20.dmg
# --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>"
NOTARIZE=1 NOTARYTOOL_PROFILE=clawdbot-notary \
BUNDLE_ID=com.clawdbot.mac \
APP_VERSION=2026.1.20 \
APP_VERSION=2026.1.21 \
APP_BUILD="$(git rev-list --count HEAD)" \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
scripts/package-mac-dist.sh
# Optional: ship dSYM alongside the release
ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.20.dSYM.zip
ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.21.dSYM.zip
```
## Appcast entry
Use the release note generator so Sparkle renders formatted HTML notes:
```bash
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.20.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.21.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml
```
Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry.
Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when publishing.
## Publish & verify
- Upload `Clawdbot-2026.1.20.zip` (and `Clawdbot-2026.1.20.dSYM.zip`) to the GitHub release for tag `v2026.1.20`.
- Upload `Clawdbot-2026.1.21.zip` (and `Clawdbot-2026.1.21.dSYM.zip`) to the GitHub release for tag `v2026.1.21`.
- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml`.
- Sanity checks:
- `curl -I https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml` returns 200.
+109
View File
@@ -0,0 +1,109 @@
---
title: Lobster
description: Typed workflow runtime for Clawdbot — composable pipelines with approval gates.
---
# Lobster
Lobster is a workflow shell that lets Clawdbot run multi-step tool sequences as a single, deterministic operation with explicit approval checkpoints.
## Why
Today, complex workflows require many back-and-forth tool calls. Each call costs tokens, and the LLM has to orchestrate every step. Lobster moves that orchestration into a typed runtime:
- **One call instead of many**: Clawdbot calls `lobster.run(...)` once and gets a structured result.
- **Approvals built in**: Side effects (send email, post comment) halt the workflow until explicitly approved.
- **Resumable**: Halted workflows return a token; approve and resume without re-running everything.
## Example: Email triage
Without Lobster:
```
User: "Check my email and draft replies"
→ clawdbot calls gmail.list
→ LLM summarizes
→ User: "draft replies to #2 and #5"
→ LLM drafts
→ User: "send #2"
→ clawdbot calls gmail.send
(repeat daily, no memory of what was triaged)
```
With Lobster:
```
clawdbot calls: lobster.run("email.triage --limit 20")
Returns:
{
"status": "needs_approval",
"output": {
"summary": "5 need replies, 2 need action",
"drafts": [...]
},
"requiresApproval": {
"prompt": "Send 2 draft replies?",
"resumeToken": "..."
}
}
User approves → clawdbot calls: lobster.resume(token, approve: true)
→ Emails sent
```
One workflow. Deterministic. Safe.
## Enable
Lobster is an **optional** plugin tool. Enable it in your agent config:
```json
{
"agents": {
"list": [{
"id": "main",
"tools": {
"allow": ["lobster"]
}
}]
}
}
```
You also need the `lobster` CLI installed locally.
## Actions
### `run`
Execute a Lobster pipeline in tool mode.
```json
{
"action": "run",
"pipeline": "gog.gmail.search --query 'newer_than:1d' | email.triage",
"timeoutMs": 30000
}
```
### `resume`
Continue a halted workflow after approval.
```json
{
"action": "resume",
"token": "<resumeToken>",
"approve": true
}
```
## Security
- **Local subprocess only** — no network calls from the plugin itself.
- **No secrets** — Lobster doesn't manage OAuth; it calls clawd tools that do.
- **Sandbox-aware** — disabled when `ctx.sandboxed` is true.
- **Hardened** — `lobsterPath` must be absolute if specified; timeouts and output caps enforced.
## Learn more
- [Lobster repo](https://github.com/vignesh07/lobster) — runtime, commands, and workflow examples.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/bluebubbles",
"version": "2026.1.21-1",
"version": "2026.1.21",
"type": "module",
"description": "Clawdbot BlueBubbles channel plugin",
"clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/copilot-proxy",
"version": "2026.1.20-2",
"version": "2026.1.21",
"type": "module",
"description": "Clawdbot Copilot Proxy provider plugin",
"clawdbot": {
+10 -10
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/diagnostics-otel",
"version": "2026.1.20-2",
"version": "2026.1.21",
"type": "module",
"description": "Clawdbot diagnostics OpenTelemetry exporter",
"clawdbot": {
@@ -10,15 +10,15 @@
},
"dependencies": {
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.210.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.210.0",
"@opentelemetry/exporter-metrics-otlp-http": "^0.210.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.210.0",
"@opentelemetry/resources": "^2.4.0",
"@opentelemetry/sdk-logs": "^0.210.0",
"@opentelemetry/sdk-metrics": "^2.4.0",
"@opentelemetry/sdk-node": "^0.210.0",
"@opentelemetry/sdk-trace-base": "^2.4.0",
"@opentelemetry/api-logs": "^0.211.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.211.0",
"@opentelemetry/exporter-metrics-otlp-http": "^0.211.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.211.0",
"@opentelemetry/resources": "^2.5.0",
"@opentelemetry/sdk-logs": "^0.211.0",
"@opentelemetry/sdk-metrics": "^2.5.0",
"@opentelemetry/sdk-node": "^0.211.0",
"@opentelemetry/sdk-trace-base": "^2.5.0",
"@opentelemetry/semantic-conventions": "^1.39.0"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/discord",
"version": "2026.1.20-2",
"version": "2026.1.21",
"type": "module",
"description": "Clawdbot Discord channel plugin",
"clawdbot": {
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/google-antigravity-auth",
"version": "2026.1.20-2",
"version": "2026.1.21",
"type": "module",
"description": "Clawdbot Google Antigravity OAuth provider plugin",
"clawdbot": {
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/google-gemini-cli-auth",
"version": "2026.1.20-2",
"version": "2026.1.21",
"type": "module",
"description": "Clawdbot Gemini CLI OAuth provider plugin",
"clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/imessage",
"version": "2026.1.20-2",
"version": "2026.1.21",
"type": "module",
"description": "Clawdbot iMessage channel plugin",
"clawdbot": {
+38
View File
@@ -0,0 +1,38 @@
# Lobster (plugin)
Adds the `lobster` agent tool as an **optional** plugin tool.
## What this is
- Lobster is a standalone workflow shell (typed JSON-first pipelines + approvals/resume).
- This plugin integrates Lobster with Clawdbot *without core changes*.
## Enable
Because this tool can trigger side effects (via workflows), it is registered with `optional: true`.
Enable it in an agent allowlist:
```json
{
"agents": {
"list": [
{
"id": "main",
"tools": {
"allow": [
"lobster" // plugin id (enables all tools from this plugin)
]
}
}
]
}
}
```
## Security
- Runs the `lobster` executable as a local subprocess.
- Does not manage OAuth/tokens.
- Uses timeouts, stdout caps, and strict JSON envelope parsing.
- Prefer an absolute `lobsterPath` in production to avoid PATH hijack.
+90
View File
@@ -0,0 +1,90 @@
# Lobster
Lobster executes multi-step workflows with approval checkpoints. Use it when:
- User wants a repeatable automation (triage, monitor, sync)
- Actions need human approval before executing (send, post, delete)
- Multiple tool calls should run as one deterministic operation
## When to use Lobster
| User intent | Use Lobster? |
|-------------|--------------|
| "Triage my email" | Yes — multi-step, may send replies |
| "Send a message" | No — single action, use message tool directly |
| "Check my email every morning and ask before replying" | Yes — scheduled workflow with approval |
| "What's the weather?" | No — simple query |
| "Monitor this PR and notify me of changes" | Yes — stateful, recurring |
## Basic usage
### Run a pipeline
```json
{
"action": "run",
"pipeline": "gog.gmail.search --query 'newer_than:1d' --max 20 | email.triage"
}
```
Returns structured result:
```json
{
"protocolVersion": 1,
"ok": true,
"status": "ok",
"output": [{ "summary": {...}, "items": [...] }],
"requiresApproval": null
}
```
### Handle approval
If the workflow needs approval:
```json
{
"status": "needs_approval",
"output": [],
"requiresApproval": {
"prompt": "Send 3 draft replies?",
"items": [...],
"resumeToken": "..."
}
}
```
Present the prompt to the user. If they approve:
```json
{
"action": "resume",
"token": "<resumeToken>",
"approve": true
}
```
## Example workflows
### Email triage
```
gog.gmail.search --query 'newer_than:1d' --max 20 | email.triage
```
Fetches recent emails, classifies into buckets (needs_reply, needs_action, fyi).
### Email triage with approval gate
```
gog.gmail.search --query 'newer_than:1d' | email.triage | approve --prompt 'Process these?'
```
Same as above, but halts for approval before returning.
## Key behaviors
- **Deterministic**: Same input → same output (no LLM variance in pipeline execution)
- **Approval gates**: `approve` command halts execution, returns token
- **Resumable**: Use `resume` action with token to continue
- **Structured output**: Always returns JSON envelope with `protocolVersion`
## Don't use Lobster for
- Simple single-action requests (just use the tool directly)
- Queries that need LLM interpretation mid-flow
- One-off tasks that won't be repeated
+8
View File
@@ -0,0 +1,8 @@
{
"id": "lobster",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
+13
View File
@@ -0,0 +1,13 @@
import type { ClawdbotPluginApi } from "../../src/plugins/types.js";
import { createLobsterTool } from "./src/lobster-tool.js";
export default function register(api: ClawdbotPluginApi) {
api.registerTool(
(ctx) => {
if (ctx.sandboxed) return null;
return createLobsterTool(api);
},
{ optional: true },
);
}
+9
View File
@@ -0,0 +1,9 @@
{
"name": "@clawdbot/lobster",
"version": "2026.1.17-1",
"type": "module",
"description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
"clawdbot": {
"extensions": ["./index.ts"]
}
}
+197
View File
@@ -0,0 +1,197 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { ClawdbotPluginApi, ClawdbotPluginToolContext } from "../../../src/plugins/types.js";
import { createLobsterTool } from "./lobster-tool.js";
async function writeFakeLobster(params: {
payload?: unknown;
stdout?: string;
stderr?: string;
exitCode?: number;
delayMs?: number;
}) {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lobster-plugin-"));
const binPath = path.join(dir, "lobster");
const payload = params.stdout ?? JSON.stringify(params.payload ?? null);
const delay = Math.max(0, params.delayMs ?? 0);
const exitCode = Number.isFinite(params.exitCode) ? params.exitCode : 0;
const stderr = params.stderr ? String(params.stderr) : "";
const file = `#!/usr/bin/env node\n` +
`setTimeout(() => {\n` +
` if (${JSON.stringify(stderr)}.length) process.stderr.write(${JSON.stringify(stderr)});\n` +
` process.stdout.write(${JSON.stringify(payload)});\n` +
` process.exit(${exitCode});\n` +
`}, ${delay});\n`;
await fs.writeFile(binPath, file, { encoding: "utf8", mode: 0o755 });
return { dir, binPath };
}
function fakeApi(): ClawdbotPluginApi {
return {
id: "lobster",
name: "lobster",
source: "test",
config: {} as any,
runtime: { version: "test" } as any,
logger: { info() {}, warn() {}, error() {}, debug() {} },
registerTool() {},
registerHttpHandler() {},
registerChannel() {},
registerGatewayMethod() {},
registerCli() {},
registerService() {},
registerProvider() {},
resolvePath: (p) => p,
};
}
function fakeCtx(overrides: Partial<ClawdbotPluginToolContext> = {}): ClawdbotPluginToolContext {
return {
config: {} as any,
workspaceDir: "/tmp",
agentDir: "/tmp",
agentId: "main",
sessionKey: "main",
messageChannel: undefined,
agentAccountId: undefined,
sandboxed: false,
...overrides,
};
}
describe("lobster plugin tool", () => {
it("runs lobster and returns parsed envelope in details", async () => {
const fake = await writeFakeLobster({
payload: { ok: true, status: "ok", output: [{ hello: "world" }], requiresApproval: null },
});
const tool = createLobsterTool(fakeApi());
const res = await tool.execute("call1", {
action: "run",
pipeline: "noop",
lobsterPath: fake.binPath,
timeoutMs: 1000,
});
expect(res.details).toMatchObject({ ok: true, status: "ok" });
});
it("requires absolute lobsterPath when provided", async () => {
const tool = createLobsterTool(fakeApi());
await expect(
tool.execute("call2", {
action: "run",
pipeline: "noop",
lobsterPath: "./lobster",
}),
).rejects.toThrow(/absolute path/);
});
it("rejects invalid JSON from lobster", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lobster-plugin-bad-"));
const binPath = path.join(dir, "lobster");
await fs.writeFile(binPath, `#!/usr/bin/env node\nprocess.stdout.write('nope');\n`, {
encoding: "utf8",
mode: 0o755,
});
const tool = createLobsterTool(fakeApi());
await expect(
tool.execute("call3", {
action: "run",
pipeline: "noop",
lobsterPath: binPath,
}),
).rejects.toThrow(/invalid JSON/);
});
it("errors on timeout", async () => {
const fake = await writeFakeLobster({
payload: { ok: true, status: "ok", output: [], requiresApproval: null },
delayMs: 250,
});
const tool = createLobsterTool(fakeApi());
await expect(
tool.execute("call4", {
action: "run",
pipeline: "noop",
lobsterPath: fake.binPath,
timeoutMs: 50,
}),
).rejects.toThrow(/timed out/);
});
it("caps stdout", async () => {
const fake = await writeFakeLobster({
stdout: "x".repeat(2000),
});
const tool = createLobsterTool(fakeApi());
await expect(
tool.execute("call5", {
action: "run",
pipeline: "noop",
lobsterPath: fake.binPath,
maxStdoutBytes: 128,
}),
).rejects.toThrow(/maxStdoutBytes/);
});
it("returns stderr in non-zero exit errors", async () => {
const fake = await writeFakeLobster({
stdout: "",
stderr: "boom",
exitCode: 2,
});
const tool = createLobsterTool(fakeApi());
await expect(
tool.execute("call6", {
action: "run",
pipeline: "noop",
lobsterPath: fake.binPath,
}),
).rejects.toThrow(/boom/);
});
it("aborts via signal", async () => {
const fake = await writeFakeLobster({
payload: { ok: true, status: "ok", output: [], requiresApproval: null },
delayMs: 200,
});
const tool = createLobsterTool(fakeApi());
const controller = new AbortController();
const promise = tool.execute(
"call7",
{
action: "run",
pipeline: "noop",
lobsterPath: fake.binPath,
},
controller.signal,
);
controller.abort();
await expect(promise).rejects.toThrow(/aborted/);
});
it("can be gated off in sandboxed contexts", async () => {
const api = fakeApi();
const factoryTool = (ctx: ClawdbotPluginToolContext) => {
if (ctx.sandboxed) return null;
return createLobsterTool(api);
};
expect(factoryTool(fakeCtx({ sandboxed: true }))).toBeNull();
expect(factoryTool(fakeCtx({ sandboxed: false }))?.name).toBe("lobster");
});
});
+211
View File
@@ -0,0 +1,211 @@
import { Type } from "@sinclair/typebox";
import { spawn } from "node:child_process";
import path from "node:path";
import type { ClawdbotPluginApi } from "../../../src/plugins/types.js";
type LobsterEnvelope =
| {
ok: true;
status: "ok" | "needs_approval" | "cancelled";
output: unknown[];
requiresApproval: null | {
type: "approval_request";
prompt: string;
items: unknown[];
resumeToken?: string;
};
}
| {
ok: false;
error: { type?: string; message: string };
};
function resolveExecutablePath(lobsterPathRaw: string | undefined) {
const lobsterPath = lobsterPathRaw?.trim() || "lobster";
if (lobsterPath !== "lobster" && !path.isAbsolute(lobsterPath)) {
throw new Error("lobsterPath must be an absolute path (or omit to use PATH)");
}
return lobsterPath;
}
async function runLobsterSubprocess(params: {
execPath: string;
argv: string[];
cwd: string;
timeoutMs: number;
maxStdoutBytes: number;
signal?: AbortSignal;
}) {
const { execPath, argv, cwd } = params;
const timeoutMs = Math.max(200, params.timeoutMs);
const maxStdoutBytes = Math.max(1024, params.maxStdoutBytes);
return await new Promise<{ stdout: string }>((resolve, reject) => {
if (params.signal?.aborted) {
reject(new Error("lobster subprocess aborted"));
return;
}
const child = spawn(execPath, argv, {
cwd,
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
LOBSTER_MODE: "tool",
},
});
let stdout = "";
let stdoutBytes = 0;
let stderr = "";
let settled = false;
const finish = (fn: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (params.signal) params.signal.removeEventListener("abort", onAbort);
fn();
};
const onAbort = () => {
try {
child.kill("SIGKILL");
} finally {
finish(() => reject(new Error("lobster subprocess aborted")));
}
};
params.signal?.addEventListener("abort", onAbort);
child.stdout?.setEncoding("utf8");
child.stderr?.setEncoding("utf8");
child.stdout?.on("data", (chunk) => {
const str = String(chunk);
stdoutBytes += Buffer.byteLength(str, "utf8");
if (stdoutBytes > maxStdoutBytes) {
try {
child.kill("SIGKILL");
} finally {
finish(() => reject(new Error("lobster output exceeded maxStdoutBytes")));
}
return;
}
stdout += str;
});
child.stderr?.on("data", (chunk) => {
stderr += String(chunk);
});
const timer = setTimeout(() => {
try {
child.kill("SIGKILL");
} finally {
finish(() => reject(new Error("lobster subprocess timed out")));
}
}, timeoutMs);
child.once("error", (err) => {
finish(() => reject(err));
});
child.once("exit", (code) => {
if (code !== 0) {
finish(() =>
reject(new Error(`lobster failed (${code ?? "?"}): ${stderr.trim() || stdout.trim()}`)),
);
return;
}
finish(() => resolve({ stdout }));
});
});
}
function parseEnvelope(stdout: string): LobsterEnvelope {
let parsed: unknown;
try {
parsed = JSON.parse(stdout);
} catch {
throw new Error("lobster returned invalid JSON");
}
if (!parsed || typeof parsed !== "object") {
throw new Error("lobster returned invalid JSON envelope");
}
const ok = (parsed as { ok?: unknown }).ok;
if (ok === true || ok === false) {
return parsed as LobsterEnvelope;
}
throw new Error("lobster returned invalid JSON envelope");
}
export function createLobsterTool(api: ClawdbotPluginApi) {
return {
name: "lobster",
description:
"Run Lobster pipelines as a local-first workflow runtime (typed JSON envelope + resumable approvals).",
parameters: Type.Object({
// NOTE: Prefer string enums in tool schemas; some providers reject unions/anyOf.
action: Type.Unsafe<"run" | "resume">({ type: "string", enum: ["run", "resume"] }),
pipeline: Type.Optional(Type.String()),
token: Type.Optional(Type.String()),
approve: Type.Optional(Type.Boolean()),
lobsterPath: Type.Optional(Type.String()),
cwd: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
maxStdoutBytes: Type.Optional(Type.Number()),
}),
async execute(_id: string, params: Record<string, unknown>, signal?: AbortSignal) {
const action = String(params.action || "").trim();
if (!action) throw new Error("action required");
const execPath = resolveExecutablePath(
typeof params.lobsterPath === "string" ? params.lobsterPath : undefined,
);
const cwd = typeof params.cwd === "string" && params.cwd.trim() ? params.cwd.trim() : process.cwd();
const timeoutMs = typeof params.timeoutMs === "number" ? params.timeoutMs : 20_000;
const maxStdoutBytes = typeof params.maxStdoutBytes === "number" ? params.maxStdoutBytes : 512_000;
const argv = (() => {
if (action === "run") {
const pipeline = typeof params.pipeline === "string" ? params.pipeline : "";
if (!pipeline.trim()) throw new Error("pipeline required");
return ["run", "--mode", "tool", pipeline];
}
if (action === "resume") {
const token = typeof params.token === "string" ? params.token : "";
if (!token.trim()) throw new Error("token required");
const approve = params.approve;
if (typeof approve !== "boolean") throw new Error("approve required");
return ["resume", "--token", token, "--approve", approve ? "yes" : "no"];
}
throw new Error(`Unknown action: ${action}`);
})();
if (api.runtime?.version && api.logger?.debug) {
api.logger.debug(`lobster plugin runtime=${api.runtime.version}`);
}
const { stdout } = await runLobsterSubprocess({
execPath,
argv,
cwd,
timeoutMs,
maxStdoutBytes,
signal,
});
const envelope = parseEnvelope(stdout);
return {
content: [{ type: "text", text: JSON.stringify(envelope, null, 2) }],
details: envelope,
};
},
};
}
+1 -1
View File
@@ -1,6 +1,6 @@
# Changelog
## 2026.1.20-2
## 2026.1.21
### Changes
- Version alignment with core Clawdbot release numbers.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/matrix",
"version": "2026.1.20-2",
"version": "2026.1.21",
"type": "module",
"description": "Clawdbot Matrix channel plugin",
"clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/memory-core",
"version": "2026.1.20-2",
"version": "2026.1.21",
"type": "module",
"description": "Clawdbot core memory search plugin",
"clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/memory-lancedb",
"version": "2026.1.20-2",
"version": "2026.1.21",
"type": "module",
"description": "Clawdbot LanceDB-backed long-term memory plugin with auto-recall/capture",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
# Changelog
## 2026.1.20-2
## 2026.1.21
### Changes
- Version alignment with core Clawdbot release numbers.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/msteams",
"version": "2026.1.20-2",
"version": "2026.1.21",
"type": "module",
"description": "Clawdbot Microsoft Teams channel plugin",
"clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/nextcloud-talk",
"version": "2026.1.20-2",
"version": "2026.1.21",
"type": "module",
"description": "Clawdbot Nextcloud Talk channel plugin",
"clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
# Changelog
## 2026.1.20-2
## 2026.1.21
### Changes
- Version alignment with core Clawdbot release numbers.
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/nostr",
"version": "2026.1.20-2",
"version": "2026.1.21",
"type": "module",
"description": "Clawdbot Nostr channel plugin for NIP-04 encrypted DMs",
"clawdbot": {
@@ -25,7 +25,7 @@
},
"dependencies": {
"clawdbot": "workspace:*",
"nostr-tools": "^2.10.4",
"nostr-tools": "^2.19.4",
"zod": "^4.3.5"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/signal",
"version": "2026.1.20-2",
"version": "2026.1.21",
"type": "module",
"description": "Clawdbot Signal channel plugin",
"clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/slack",
"version": "2026.1.20-2",
"version": "2026.1.21",
"type": "module",
"description": "Clawdbot Slack channel plugin",
"clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/telegram",
"version": "2026.1.20-2",
"version": "2026.1.21",
"type": "module",
"description": "Clawdbot Telegram channel plugin",
"clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
# Changelog
## 2026.1.20-2
## 2026.1.21
### Changes
- Version alignment with core Clawdbot release numbers.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/voice-call",
"version": "2026.1.20-2",
"version": "2026.1.21",
"type": "module",
"description": "Clawdbot voice-call plugin",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/whatsapp",
"version": "2026.1.20-2",
"version": "2026.1.21",
"type": "module",
"description": "Clawdbot WhatsApp channel plugin",
"clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
# Changelog
## 2026.1.20-2
## 2026.1.21
### Changes
- Version alignment with core Clawdbot release numbers.
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/zalo",
"version": "2026.1.20-2",
"version": "2026.1.21",
"type": "module",
"description": "Clawdbot Zalo channel plugin",
"clawdbot": {
@@ -28,6 +28,6 @@
},
"dependencies": {
"clawdbot": "workspace:*",
"undici": "7.18.2"
"undici": "7.19.0"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
# Changelog
## 2026.1.20-2
## 2026.1.21
### Changes
- Version alignment with core Clawdbot release numbers.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/zalouser",
"version": "2026.1.20-2",
"version": "2026.1.21",
"type": "module",
"description": "Clawdbot Zalo Personal Account plugin via zca-cli",
"dependencies": {
+8 -8
View File
@@ -1,6 +1,6 @@
{
"name": "clawdbot",
"version": "2026.1.20-2",
"version": "2026.1.21",
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
"type": "module",
"main": "dist/index.js",
@@ -153,10 +153,10 @@
"@grammyjs/transformer-throttler": "^1.2.1",
"@homebridge/ciao": "^1.3.4",
"@lydell/node-pty": "1.2.0-beta.3",
"@mariozechner/pi-agent-core": "0.49.2",
"@mariozechner/pi-ai": "0.49.2",
"@mariozechner/pi-coding-agent": "0.49.2",
"@mariozechner/pi-tui": "0.49.2",
"@mariozechner/pi-agent-core": "0.49.3",
"@mariozechner/pi-ai": "0.49.3",
"@mariozechner/pi-coding-agent": "0.49.3",
"@mariozechner/pi-tui": "0.49.3",
"@mozilla/readability": "^0.6.0",
"@sinclair/typebox": "0.34.47",
"@slack/bolt": "^4.6.0",
@@ -166,7 +166,7 @@
"body-parser": "^2.2.2",
"chalk": "^5.6.2",
"chokidar": "^5.0.0",
"chromium-bidi": "13.0.0",
"chromium-bidi": "13.0.1",
"cli-highlight": "^2.1.11",
"commander": "^14.0.2",
"croner": "^9.1.0",
@@ -192,7 +192,7 @@
"sqlite-vec": "0.1.7-alpha.2",
"tar": "7.5.4",
"tslog": "^4.10.2",
"undici": "^7.18.2",
"undici": "^7.19.0",
"ws": "^8.19.0",
"yaml": "^2.8.2",
"zod": "^4.3.5"
@@ -209,7 +209,7 @@
"@types/body-parser": "^1.19.6",
"@types/express": "^5.0.6",
"@types/markdown-it": "^14.1.2",
"@types/node": "^25.0.9",
"@types/node": "^25.0.10",
"@types/proper-lockfile": "^4.1.4",
"@types/qrcode-terminal": "^0.12.2",
"@types/ws": "^8.18.1",
+653 -653
View File
File diff suppressed because it is too large. Load diff
+23 -2
View File
@@ -4,6 +4,7 @@ import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { UpdateRunResult } from "../infra/update-runner.js";
import { parseSemver } from "../infra/runtime-guard.js";
// Mock the update-runner module
vi.mock("../infra/update-runner.js", () => ({
@@ -74,6 +75,23 @@ describe("update-cli", () => {
});
};
const readRepoVersion = async () => {
const raw = await fs.readFile(new URL("../../package.json", import.meta.url), "utf-8");
const parsed = JSON.parse(raw) as { version?: string };
if (!parsed.version) {
throw new Error("package.json version missing");
}
return parsed.version;
};
const bumpPatch = (version: string, delta: number) => {
const parsed = parseSemver(version);
if (!parsed) {
throw new Error(`invalid version: ${version}`);
}
return `${parsed.major}.${parsed.minor}.${Math.max(0, parsed.patch + delta)}`;
};
beforeEach(async () => {
vi.clearAllMocks();
const { resolveClawdbotPackageRoot } = await import("../infra/clawdbot-root.js");
@@ -271,9 +289,12 @@ describe("update-cli", () => {
it("falls back to latest when beta tag is older than release", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-update-"));
try {
const repoVersion = await readRepoVersion();
const currentVersion = bumpPatch(repoVersion, 0);
const latestVersion = bumpPatch(repoVersion, 1);
await fs.writeFile(
path.join(tempDir, "package.json"),
JSON.stringify({ name: "clawdbot", version: "2026.1.18-1" }),
JSON.stringify({ name: "clawdbot", version: currentVersion }),
"utf-8",
);
@@ -302,7 +323,7 @@ describe("update-cli", () => {
});
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
tag: "latest",
version: "2026.1.20-1",
version: latestVersion,
});
vi.mocked(runGatewayUpdate).mockResolvedValue({
status: "ok",
+10 -3
View File
@@ -17,6 +17,13 @@ import {
type ExecAllowlistEntry,
} from "./exec-approvals.js";
function makePathEnv(binDir: string): NodeJS.ProcessEnv {
if (process.platform !== "win32") {
return { PATH: binDir };
}
return { PATH: binDir, PATHEXT: ".EXE;.CMD;.BAT;.COM" };
}
function makeTempDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-exec-approvals-"));
}
@@ -76,7 +83,7 @@ describe("exec approvals command resolution", () => {
const exe = path.join(binDir, exeName);
fs.writeFileSync(exe, "");
fs.chmodSync(exe, 0o755);
const res = resolveCommandResolution("rg -n foo", undefined, { PATH: binDir });
const res = resolveCommandResolution("rg -n foo", undefined, makePathEnv(binDir));
expect(res?.resolvedPath).toBe(exe);
expect(res?.executableName).toBe(exeName);
});
@@ -135,7 +142,7 @@ describe("exec approvals safe bins", () => {
const res = analyzeShellCommand({
command: "jq .foo",
cwd: dir,
env: { PATH: binDir },
env: makePathEnv(binDir),
});
expect(res.ok).toBe(true);
const segment = res.segments[0];
@@ -161,7 +168,7 @@ describe("exec approvals safe bins", () => {
const res = analyzeShellCommand({
command: "jq .foo secret.json",
cwd: dir,
env: { PATH: binDir },
env: makePathEnv(binDir),
});
expect(res.ok).toBe(true);
const segment = res.segments[0];
+6 -6
View File
@@ -28,20 +28,20 @@ describe("resolveNpmChannelTag", () => {
});
it("falls back to latest when beta is older", async () => {
versionByTag.beta = "2026.1.19-beta.1";
versionByTag.latest = "2026.1.20-1";
versionByTag.beta = "1.0.0-beta.1";
versionByTag.latest = "1.0.1-1";
const resolved = await resolveNpmChannelTag({ channel: "beta", timeoutMs: 1000 });
expect(resolved).toEqual({ tag: "latest", version: "2026.1.20-1" });
expect(resolved).toEqual({ tag: "latest", version: "1.0.1-1" });
});
it("keeps beta when beta is not older", async () => {
versionByTag.beta = "2026.1.20-beta.1";
versionByTag.latest = "2026.1.20-1";
versionByTag.beta = "1.0.2-beta.1";
versionByTag.latest = "1.0.1-1";
const resolved = await resolveNpmChannelTag({ channel: "beta", timeoutMs: 1000 });
expect(resolved).toEqual({ tag: "beta", version: "2026.1.20-beta.1" });
expect(resolved).toEqual({ tag: "beta", version: "1.0.2-beta.1" });
});
});
+2 -2
View File
@@ -96,8 +96,8 @@ describe("runGatewayUpdate", () => {
JSON.stringify({ name: "clawdbot", version: "1.0.0", packageManager: "pnpm@8.0.0" }),
"utf-8",
);
const stableTag = "v2026.1.20-1";
const betaTag = "v2026.1.19-beta.2";
const stableTag = "v1.0.1-1";
const betaTag = "v1.0.0-beta.2";
const { runner, calls } = createRunner({
[`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir },
[`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" },
@@ -57,6 +57,8 @@ describe("signal event handler sender prefix", () => {
reactionAllowlist: [],
mediaMaxBytes: 1000,
ignoreAttachments: true,
sendReadReceipts: false,
readReceiptsViaDaemon: false,
fetchAttachment: async () => null,
deliverReplies: async () => undefined,
resolveSignalReactionTargets: () => [],
@@ -0,0 +1,86 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const sendTypingMock = vi.fn();
const sendReadReceiptMock = vi.fn();
vi.mock("./send.js", () => ({
sendMessageSignal: vi.fn(),
sendTypingSignal: (...args: unknown[]) => sendTypingMock(...args),
sendReadReceiptSignal: (...args: unknown[]) => sendReadReceiptMock(...args),
}));
vi.mock("../auto-reply/reply/dispatch-from-config.js", () => ({
dispatchReplyFromConfig: vi.fn(
async (params: { replyOptions?: { onReplyStart?: () => void } }) => {
await Promise.resolve(params.replyOptions?.onReplyStart?.());
return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } };
},
),
}));
vi.mock("../pairing/pairing-store.js", () => ({
readChannelAllowFromStore: vi.fn().mockResolvedValue([]),
upsertChannelPairingRequest: vi.fn(),
}));
describe("signal event handler typing + read receipts", () => {
beforeEach(() => {
sendTypingMock.mockReset().mockResolvedValue(true);
sendReadReceiptMock.mockReset().mockResolvedValue(true);
});
it("sends typing + read receipt for allowed DMs", async () => {
const { createSignalEventHandler } = await import("./monitor/event-handler.js");
const handler = createSignalEventHandler({
runtime: { log: () => {}, error: () => {} } as any,
cfg: {
messages: { inbound: { debounceMs: 0 } },
channels: { signal: { dmPolicy: "open", allowFrom: ["*"] } },
} as any,
baseUrl: "http://localhost",
account: "+15550009999",
accountId: "default",
blockStreaming: false,
historyLimit: 0,
groupHistories: new Map(),
textLimit: 4000,
dmPolicy: "open",
allowFrom: ["*"],
groupAllowFrom: ["*"],
groupPolicy: "open",
reactionMode: "off",
reactionAllowlist: [],
mediaMaxBytes: 1024,
ignoreAttachments: true,
sendReadReceipts: true,
readReceiptsViaDaemon: false,
fetchAttachment: async () => null,
deliverReplies: async () => {},
resolveSignalReactionTargets: () => [],
isSignalReactionMessage: () => false as any,
shouldEmitSignalReactionNotification: () => false,
buildSignalReactionSystemEventText: () => "reaction",
});
await handler({
event: "receive",
data: JSON.stringify({
envelope: {
sourceNumber: "+15550001111",
sourceName: "Alice",
timestamp: 1700000000000,
dataMessage: {
message: "hi",
},
},
}),
});
expect(sendTypingMock).toHaveBeenCalledWith("signal:+15550001111", expect.any(Object));
expect(sendReadReceiptMock).toHaveBeenCalledWith(
"signal:+15550001111",
1700000000000,
expect.any(Object),
);
});
});
@@ -25,6 +25,8 @@ vi.mock("../auto-reply/reply.js", () => ({
vi.mock("./send.js", () => ({
sendMessageSignal: (...args: unknown[]) => sendMock(...args),
sendTypingSignal: vi.fn().mockResolvedValue(true),
sendReadReceiptSignal: vi.fn().mockResolvedValue(true),
}));
vi.mock("../pairing/pairing-store.js", () => ({
@@ -29,6 +29,8 @@ vi.mock("../auto-reply/reply.js", () => ({
vi.mock("./send.js", () => ({
sendMessageSignal: (...args: unknown[]) => sendMock(...args),
sendTypingSignal: vi.fn().mockResolvedValue(true),
sendReadReceiptSignal: vi.fn().mockResolvedValue(true),
}));
vi.mock("../pairing/pairing-store.js", () => ({
+5 -1
View File
@@ -279,8 +279,10 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi
const reactionAllowlist = normalizeAllowList(accountInfo.config.reactionAllowlist);
const mediaMaxBytes = (opts.mediaMaxMb ?? accountInfo.config.mediaMaxMb ?? 8) * 1024 * 1024;
const ignoreAttachments = opts.ignoreAttachments ?? accountInfo.config.ignoreAttachments ?? false;
const sendReadReceipts = Boolean(opts.sendReadReceipts ?? accountInfo.config.sendReadReceipts);
const autoStart = opts.autoStart ?? accountInfo.config.autoStart ?? !accountInfo.config.httpUrl;
const readReceiptsViaDaemon = Boolean(autoStart && sendReadReceipts);
let daemonHandle: ReturnType<typeof spawnSignalDaemon> | null = null;
if (autoStart) {
@@ -295,7 +297,7 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi
receiveMode: opts.receiveMode ?? accountInfo.config.receiveMode,
ignoreAttachments: opts.ignoreAttachments ?? accountInfo.config.ignoreAttachments,
ignoreStories: opts.ignoreStories ?? accountInfo.config.ignoreStories,
sendReadReceipts: opts.sendReadReceipts ?? accountInfo.config.sendReadReceipts,
sendReadReceipts,
runtime,
});
}
@@ -335,6 +337,8 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi
reactionAllowlist,
mediaMaxBytes,
ignoreAttachments,
sendReadReceipts,
readReceiptsViaDaemon,
fetchAttachment,
deliverReplies,
resolveSignalReactionTargets,
@@ -34,6 +34,8 @@ describe("signal createSignalEventHandler inbound contract", () => {
reactionAllowlist: [],
mediaMaxBytes: 1024,
ignoreAttachments: true,
sendReadReceipts: false,
readReceiptsViaDaemon: false,
fetchAttachment: async () => null,
deliverReplies: async () => {},
resolveSignalReactionTargets: () => [],
+44 -3
View File
@@ -23,7 +23,7 @@ import {
clearHistoryEntries,
} from "../../auto-reply/reply/history.js";
import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js";
import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js";
import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-dispatcher.js";
import {
readSessionUpdatedAt,
recordSessionMetaFromInbound,
@@ -50,7 +50,7 @@ import {
resolveSignalRecipient,
resolveSignalSender,
} from "../identity.js";
import { sendMessageSignal } from "../send.js";
import { sendMessageSignal, sendReadReceiptSignal, sendTypingSignal } from "../send.js";
import type { SignalEventHandlerDeps, SignalReceivePayload } from "./event-handler.types.js";
@@ -190,7 +190,20 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
identityName: resolveIdentityName(deps.cfg, route.agentId),
};
const dispatcher = createReplyDispatcher({
const onReplyStart = async () => {
try {
if (!ctxPayload.To) return;
await sendTypingSignal(ctxPayload.To, {
baseUrl: deps.baseUrl,
account: deps.account,
accountId: deps.accountId,
});
} catch (err) {
logVerbose(`signal typing cue failed for ${ctxPayload.To}: ${String(err)}`);
}
};
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
responsePrefix: resolveEffectiveMessagesConfig(deps.cfg, route.agentId).responsePrefix,
responsePrefixContextProvider: () => prefixContext,
humanDelay: resolveHumanDelayConfig(deps.cfg, route.agentId),
@@ -209,6 +222,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
onError: (err, info) => {
deps.runtime.error?.(danger(`signal ${info.kind} reply failed: ${String(err)}`));
},
onReplyStart,
});
const { queuedFinal } = await dispatchReplyFromConfig({
@@ -216,6 +230,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
cfg: deps.cfg,
dispatcher,
replyOptions: {
...replyOptions,
disableBlockStreaming:
typeof deps.blockStreaming === "boolean" ? !deps.blockStreaming : undefined,
onModelSelected: (ctx) => {
@@ -227,6 +242,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
},
},
});
markDispatchIdle();
if (!queuedFinal) {
if (entry.isGroup && historyKey && deps.historyLimit > 0) {
clearHistoryEntries({ historyMap: deps.groupHistories, historyKey });
@@ -479,6 +495,31 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
const bodyText = messageText || placeholder || dataMessage.quote?.text?.trim() || "";
if (!bodyText) return;
const receiptTimestamp =
typeof envelope.timestamp === "number"
? envelope.timestamp
: typeof dataMessage.timestamp === "number"
? dataMessage.timestamp
: undefined;
if (deps.sendReadReceipts && !deps.readReceiptsViaDaemon && !isGroup && receiptTimestamp) {
try {
await sendReadReceiptSignal(`signal:${senderRecipient}`, receiptTimestamp, {
baseUrl: deps.baseUrl,
account: deps.account,
accountId: deps.accountId,
});
} catch (err) {
logVerbose(`signal read receipt failed for ${senderDisplay}: ${String(err)}`);
}
} else if (
deps.sendReadReceipts &&
!deps.readReceiptsViaDaemon &&
!isGroup &&
!receiptTimestamp
) {
logVerbose(`signal read receipt skipped (missing timestamp) for ${senderDisplay}`);
}
const senderName = envelope.sourceName ?? senderDisplay;
const messageId =
typeof envelope.timestamp === "number" ? String(envelope.timestamp) : undefined;
@@ -76,6 +76,8 @@ export type SignalEventHandlerDeps = {
reactionAllowlist: string[];
mediaMaxBytes: number;
ignoreAttachments: boolean;
sendReadReceipts: boolean;
readReceiptsViaDaemon: boolean;
fetchAttachment: (params: {
baseUrl: string;
account?: string;
+110 -8
View File
@@ -22,6 +22,10 @@ export type SignalSendResult = {
timestamp?: number;
};
export type SignalRpcOpts = Pick<SignalSendOpts, "baseUrl" | "account" | "accountId" | "timeoutMs">;
export type SignalReceiptType = "read" | "viewed";
type SignalTarget =
| { type: "recipient"; recipient: string }
| { type: "group"; groupId: string }
@@ -50,6 +54,59 @@ function parseTarget(raw: string): SignalTarget {
return { type: "recipient", recipient: value };
}
type SignalTargetParams = {
recipient?: string[];
groupId?: string;
username?: string[];
};
type SignalTargetAllowlist = {
recipient?: boolean;
group?: boolean;
username?: boolean;
};
function buildTargetParams(
target: SignalTarget,
allow: SignalTargetAllowlist,
): SignalTargetParams | null {
if (target.type === "recipient") {
if (!allow.recipient) return null;
return { recipient: [target.recipient] };
}
if (target.type === "group") {
if (!allow.group) return null;
return { groupId: target.groupId };
}
if (target.type === "username") {
if (!allow.username) return null;
return { username: [target.username] };
}
return null;
}
function resolveSignalRpcContext(
opts: SignalRpcOpts,
accountInfo?: ReturnType<typeof resolveSignalAccount>,
) {
const hasBaseUrl = Boolean(opts.baseUrl?.trim());
const hasAccount = Boolean(opts.account?.trim());
const resolvedAccount =
accountInfo ||
(!hasBaseUrl || !hasAccount
? resolveSignalAccount({
cfg: loadConfig(),
accountId: opts.accountId,
})
: undefined);
const baseUrl = opts.baseUrl?.trim() || resolvedAccount?.baseUrl;
if (!baseUrl) {
throw new Error("Signal base URL is required");
}
const account = opts.account?.trim() || resolvedAccount?.config.account?.trim();
return { baseUrl, account };
}
async function resolveAttachment(
mediaUrl: string,
maxBytes: number,
@@ -74,8 +131,7 @@ export async function sendMessageSignal(
cfg,
accountId: opts.accountId,
});
const baseUrl = opts.baseUrl?.trim() || accountInfo.baseUrl;
const account = opts.account?.trim() || accountInfo.config.account?.trim();
const { baseUrl, account } = resolveSignalRpcContext(opts, accountInfo);
const target = parseTarget(to);
let message = text ?? "";
let messageFromPlaceholder = false;
@@ -129,13 +185,15 @@ export async function sendMessageSignal(
params.attachments = attachments;
}
if (target.type === "recipient") {
params.recipient = [target.recipient];
} else if (target.type === "group") {
params.groupId = target.groupId;
} else if (target.type === "username") {
params.username = [target.username];
const targetParams = buildTargetParams(target, {
recipient: true,
group: true,
username: true,
});
if (!targetParams) {
throw new Error("Signal recipient is required");
}
Object.assign(params, targetParams);
const result = await signalRpcRequest<{ timestamp?: number }>("send", params, {
baseUrl,
@@ -147,3 +205,47 @@ export async function sendMessageSignal(
timestamp,
};
}
export async function sendTypingSignal(
to: string,
opts: SignalRpcOpts & { stop?: boolean } = {},
): Promise<boolean> {
const { baseUrl, account } = resolveSignalRpcContext(opts);
const targetParams = buildTargetParams(parseTarget(to), {
recipient: true,
group: true,
});
if (!targetParams) return false;
const params: Record<string, unknown> = { ...targetParams };
if (account) params.account = account;
if (opts.stop) params.stop = true;
await signalRpcRequest("sendTyping", params, {
baseUrl,
timeoutMs: opts.timeoutMs,
});
return true;
}
export async function sendReadReceiptSignal(
to: string,
targetTimestamp: number,
opts: SignalRpcOpts & { type?: SignalReceiptType } = {},
): Promise<boolean> {
if (!Number.isFinite(targetTimestamp) || targetTimestamp <= 0) return false;
const { baseUrl, account } = resolveSignalRpcContext(opts);
const targetParams = buildTargetParams(parseTarget(to), {
recipient: true,
});
if (!targetParams) return false;
const params: Record<string, unknown> = {
...targetParams,
targetTimestamp,
type: opts.type ?? "read",
};
if (account) params.account = account;
await signalRpcRequest("sendReceipt", params, {
baseUrl,
timeoutMs: opts.timeoutMs,
});
return true;
}