Compare commits

..
Author SHA1 Message Date
Peter Steinberger 43b55a8c3a fix: enforce strict exec approval optionals (#1414) (thanks @czekaj) 2026-01-22 03:11:06 +00:00
Lucas Czekaj fc6a35797a fix(exec): pass undefined instead of null for optional approval params
TypeBox Type.Optional(Type.String()) accepts string|undefined but NOT null.
Discord exec was failing with 'resolvedPath must be string' because callers
passed null explicitly. Web UI worked because it skipped the approval request.

Fixes exec approval validation error in Discord-triggered sessions.
2026-01-22 02:54:36 +00:00
Peter Steinberger 464de2978b docs: add special thanks 2026-01-22 02:48:17 +00:00
Peter Steinberger 9d22646120 fix: reduce invalid config log noise 2026-01-22 02:48:01 +00:00
Peter Steinberger f1aa260b0e test: avoid downgrade prompt in update fallback 2026-01-22 02:44:13 +00:00
Peter Steinberger b5c307d07f docs: highlight lobster in changelog 2026-01-22 02:37:26 +00:00
Peter Steinberger 2e1514095d fix: package Textual resources for mac app 2026-01-22 02:34:27 +00:00
Peter Steinberger f4b3f33c8e Merge pull request #1152 from vignesh07/feat/lobster-plugin
feat: Add optional lobster plugin tool (typed workflows, approvals/resume)
2026-01-22 02:34:05 +00:00
Peter Steinberger 2d1d793651 Merge pull request #1373 from yazinsai/main
Add auto-refresh polling for debug view
2026-01-22 02:25:24 +00:00
Peter Steinberger 2f47b3f6bd fix: sync debug polling with route changes (#1373) (thanks @yazinsai) 2026-01-22 02:24:19 +00:00
Peter Steinberger 302bb64457 test: fix await-thenable in signal typing test 2026-01-22 02:20:42 +00:00
Peter Steinberger 47ebe29195 test: stabilize exec approvals path resolution 2026-01-22 02:07:40 +00:00
Peter Steinberger cc74e0d188 feat(signal): add typing + read receipts 2026-01-22 02:04:59 +00:00
Yazin d7d98c3971 Add auto-refresh polling for debug view
The debug view now automatically refreshes every 3 seconds when active,
similar to the logs view. This removes the need to manually click the
refresh button to see updated debug messages and status information.
2026-01-22 02:03:40 +00:00
Peter Steinberger 5bf7a9d0db test: avoid hardcoded version strings 2026-01-22 02:01:11 +00:00
Peter Steinberger 3ad0d2fe23 chore: bump version to 2026.1.21 2026-01-22 01:59:16 +00:00
Peter Steinberger da98528651 Merge pull request #1256 from zknicker/feat/heartbeat-session-target
feat: configurable heartbeat session
2026-01-22 01:50:53 +00:00
Peter Steinberger 75dd1781b7 fix(macos): clear stale gateway failures 2026-01-22 01:48:41 +00:00
Peter Steinberger 1b947dcdf9 chore: update dependencies 2026-01-22 01:47:43 +00:00
Vignesh Natarajan 9497ffcc50 Add SKILL.md to teach Clawdbot when/how to use Lobster 2026-01-18 12:11:25 -08:00
Vignesh Natarajan 032c780a79 Add lobster.md documentation 2026-01-18 11:07:47 -08:00
Vignesh Natarajan e011c764a7 Gate lobster plugin tool in sandboxed contexts 2026-01-17 20:33:31 -08:00
Vignesh Natarajan b2650ba672 Move lobster integration to optional plugin tool 2026-01-17 20:18:54 -08:00
Vignesh Natarajan 147fccd967 Add lobster tool for running local Lobster pipelines 2026-01-17 20:13:00 -08:00
71 changed files with 1995 additions and 770 deletions

No files matched your search

+6
View File
@@ -5,11 +5,17 @@ Docs: https://docs.clawd.bot
## 2026.1.22
### Changes
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster
- 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
- Config: avoid stack traces for invalid configs and log the config path.
- Doctor: warn when gateway.mode is unset with configure/config guidance.
- macOS: include Textual syntax highlighting resources in packaged app to prevent chat crashes. (#1362)
- Exec approvals: send optional approval params as undefined instead of null. (#1414) Thanks @czekaj.
- UI: refresh debug panel on route-driven tab changes. (#1373) Thanks @yazinsai.
## 2026.1.21
+3
View File
@@ -471,6 +471,9 @@ by Peter Steinberger and the community.
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
AI/vibe-coded PRs welcome! 🤖
Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and for
[pi-mono](https://github.com/badlogic/pi-mono).
Thanks to all clawtributors:
<p align="left">
+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>`.
+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"
→ clawd calls gmail.list
→ LLM summarizes
→ User: "draft replies to #2 and #5"
→ LLM drafts
→ User: "send #2"
→ clawd calls gmail.send
(repeat daily, no memory of what was triaged)
```
With Lobster:
```
clawd 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 → clawd 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
+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"]
}
}
+112
View File
@@ -0,0 +1,112 @@
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;
}) {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lobster-plugin-"));
const binPath = path.join(dir, "lobster");
const file = `#!/usr/bin/env node\n` +
`process.stdout.write(JSON.stringify(${JSON.stringify(params.payload)}));\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("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");
});
});
+185
View File
@@ -0,0 +1,185 @@
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;
}) {
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) => {
const child = spawn(execPath, argv, {
cwd,
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
LOBSTER_MODE: "tool",
},
});
let stdout = "";
let stdoutBytes = 0;
let stderr = "";
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 {
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 {
reject(new Error("lobster subprocess timed out"));
}
}, timeoutMs);
child.once("error", (err) => {
clearTimeout(timer);
reject(err);
});
child.once("exit", (code) => {
clearTimeout(timer);
if (code !== 0) {
reject(new Error(`lobster failed (${code ?? "?"}): ${stderr.trim() || stdout.trim()}`));
return;
}
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>) {
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,
});
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
+19 -5
View File
@@ -227,15 +227,29 @@ fi
echo "📦 Copying Textual resources"
TEXTUAL_BUNDLE_DIR="$(build_path_for_arch "$PRIMARY_ARCH")/$BUILD_CONFIG"
TEXTUAL_BUNDLE="$TEXTUAL_BUNDLE_DIR/textual_Textual.bundle"
if [ ! -d "$TEXTUAL_BUNDLE" ]; then
TEXTUAL_BUNDLE="$TEXTUAL_BUNDLE_DIR/Textual_Textual.bundle"
TEXTUAL_BUNDLE=""
for candidate in \
"$TEXTUAL_BUNDLE_DIR/textual_Textual.bundle" \
"$TEXTUAL_BUNDLE_DIR/Textual_Textual.bundle"
do
if [ -d "$candidate" ]; then
TEXTUAL_BUNDLE="$candidate"
break
fi
done
if [ -z "$TEXTUAL_BUNDLE" ]; then
TEXTUAL_BUNDLE="$(find "$BUILD_ROOT" -type d \( -name "textual_Textual.bundle" -o -name "Textual_Textual.bundle" \) -print -quit)"
fi
if [ -d "$TEXTUAL_BUNDLE" ]; then
if [ -n "$TEXTUAL_BUNDLE" ] && [ -d "$TEXTUAL_BUNDLE" ]; then
rm -rf "$APP_ROOT/Contents/Resources/$(basename "$TEXTUAL_BUNDLE")"
cp -R "$TEXTUAL_BUNDLE" "$APP_ROOT/Contents/Resources/"
else
echo "WARN: Textual resource bundle not found in $TEXTUAL_BUNDLE_DIR (continuing)" >&2
if [[ "${ALLOW_MISSING_TEXTUAL_BUNDLE:-0}" == "1" ]]; then
echo "WARN: Textual resource bundle not found (continuing due to ALLOW_MISSING_TEXTUAL_BUNDLE=1)" >&2
else
echo "ERROR: Textual resource bundle not found. Set ALLOW_MISSING_TEXTUAL_BUNDLE=1 to bypass." >&2
exit 1
fi
fi
echo "⏹ Stopping any running Clawdbot"
+4 -4
View File
@@ -896,8 +896,8 @@ export function createExecTool(
security: hostSecurity,
ask: hostAsk,
agentId: defaults?.agentId,
resolvedPath: null,
sessionKey: defaults?.sessionKey ?? null,
resolvedPath: undefined,
sessionKey: defaults?.sessionKey,
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
},
)) as { decision?: string } | null;
@@ -1060,7 +1060,7 @@ export function createExecTool(
const approvalSlug = createApprovalSlug(approvalId);
const expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS;
const contextKey = `exec:${approvalId}`;
const resolvedPath = analysis.segments[0]?.resolution?.resolvedPath ?? null;
const resolvedPath = analysis.segments[0]?.resolution?.resolvedPath;
const noticeSeconds = Math.max(1, Math.round(approvalRunningNoticeMs / 1000));
const commandText = params.command;
const effectiveTimeout =
@@ -1082,7 +1082,7 @@ export function createExecTool(
ask: hostAsk,
agentId: defaults?.agentId,
resolvedPath,
sessionKey: defaults?.sessionKey ?? null,
sessionKey: defaults?.sessionKey,
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
},
)) as { decision?: string } | null;
+2 -2
View File
@@ -267,8 +267,8 @@ export function registerNodesInvokeCommands(nodes: Command) {
security: hostSecurity,
ask: hostAsk,
agentId,
resolvedPath: null,
sessionKey: null,
resolvedPath: undefined,
sessionKey: undefined,
timeoutMs: 120_000,
})) as { decision?: string } | null;
const decision =
+3 -3
View File
@@ -236,7 +236,7 @@ describe("update-cli", () => {
durationMs: 100,
});
await updateCommand({});
await updateCommand({ yes: true });
const call = vi.mocked(runGatewayUpdate).mock.calls[0]?.[0];
expect(call?.channel).toBe("stable");
@@ -273,7 +273,7 @@ describe("update-cli", () => {
try {
await fs.writeFile(
path.join(tempDir, "package.json"),
JSON.stringify({ name: "clawdbot", version: "2026.1.18-1" }),
JSON.stringify({ name: "clawdbot", version: "1.0.0" }),
"utf-8",
);
@@ -302,7 +302,7 @@ describe("update-cli", () => {
});
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
tag: "latest",
version: "2026.1.20-1",
version: "1.2.3-1",
});
vi.mocked(runGatewayUpdate).mockResolvedValue({
status: "ok",
+5 -1
View File
@@ -247,7 +247,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
.join("\n");
if (!loggedInvalidConfigs.has(configPath)) {
loggedInvalidConfigs.add(configPath);
deps.logger.error(`Invalid config:\\n${details}`);
deps.logger.error(`Invalid config at ${configPath}:\\n${details}`);
}
const error = new Error("Invalid config");
(error as { code?: string; details?: string }).code = "INVALID_CONFIG";
@@ -299,6 +299,10 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
deps.logger.error(err.message);
throw err;
}
const error = err as { code?: string };
if (error?.code === "INVALID_CONFIG") {
return {};
}
deps.logger.error(`Failed to read config at ${configPath}`, err);
return {};
}
@@ -1,10 +1,93 @@
import { describe, expect, it, vi } from "vitest";
import { ExecApprovalManager } from "../exec-approval-manager.js";
import { createExecApprovalHandlers } from "./exec-approval.js";
import { validateExecApprovalRequestParams } from "../protocol/index.js";
const noop = () => {};
describe("exec approval handlers", () => {
describe("ExecApprovalRequestParams validation", () => {
it("accepts request with resolvedPath omitted", () => {
const params = {
command: "echo hi",
cwd: "/tmp",
host: "node",
};
expect(validateExecApprovalRequestParams(params)).toBe(true);
});
it("accepts request with resolvedPath as string", () => {
const params = {
command: "echo hi",
cwd: "/tmp",
host: "node",
resolvedPath: "/usr/bin/echo",
};
expect(validateExecApprovalRequestParams(params)).toBe(true);
});
it("accepts request with resolvedPath as undefined", () => {
const params = {
command: "echo hi",
cwd: "/tmp",
host: "node",
resolvedPath: undefined,
};
expect(validateExecApprovalRequestParams(params)).toBe(true);
});
// This documents the TypeBox/AJV behavior that caused the Discord exec bug:
// Type.Optional(Type.String()) does NOT accept null, only string or undefined.
it("rejects request with resolvedPath as null", () => {
const params = {
command: "echo hi",
cwd: "/tmp",
host: "node",
resolvedPath: null,
};
expect(validateExecApprovalRequestParams(params)).toBe(false);
});
it("accepts request with sessionKey omitted", () => {
const params = {
command: "echo hi",
cwd: "/tmp",
host: "node",
};
expect(validateExecApprovalRequestParams(params)).toBe(true);
});
it("accepts request with sessionKey as string", () => {
const params = {
command: "echo hi",
cwd: "/tmp",
host: "node",
sessionKey: "session-1",
};
expect(validateExecApprovalRequestParams(params)).toBe(true);
});
it("accepts request with sessionKey as undefined", () => {
const params = {
command: "echo hi",
cwd: "/tmp",
host: "node",
sessionKey: undefined,
};
expect(validateExecApprovalRequestParams(params)).toBe(true);
});
it("rejects request with sessionKey as null", () => {
const params = {
command: "echo hi",
cwd: "/tmp",
host: "node",
sessionKey: null,
};
expect(validateExecApprovalRequestParams(params)).toBe(false);
});
});
it("broadcasts request + resolve", async () => {
const manager = new ExecApprovalManager();
const handlers = createExecApprovalHandlers(manager);
+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;
}
+6
View File
@@ -14,6 +14,8 @@ import {
startNodesPolling,
stopLogsPolling,
stopNodesPolling,
startDebugPolling,
stopDebugPolling,
} from "./app-polling";
type LifecycleHost = {
@@ -52,6 +54,9 @@ export function handleConnected(host: LifecycleHost) {
if (host.tab === "logs") {
startLogsPolling(host as unknown as Parameters<typeof startLogsPolling>[0]);
}
if (host.tab === "debug") {
startDebugPolling(host as unknown as Parameters<typeof startDebugPolling>[0]);
}
}
export function handleFirstUpdated(host: LifecycleHost) {
@@ -62,6 +67,7 @@ export function handleDisconnected(host: LifecycleHost) {
window.removeEventListener("popstate", host.popStateHandler);
stopNodesPolling(host as unknown as Parameters<typeof stopNodesPolling>[0]);
stopLogsPolling(host as unknown as Parameters<typeof stopLogsPolling>[0]);
stopDebugPolling(host as unknown as Parameters<typeof stopDebugPolling>[0]);
detachThemeListener(
host as unknown as Parameters<typeof detachThemeListener>[0],
);
+16
View File
@@ -1,10 +1,12 @@
import { loadLogs } from "./controllers/logs";
import { loadNodes } from "./controllers/nodes";
import { loadDebug } from "./controllers/debug";
import type { ClawdbotApp } from "./app";
type PollingHost = {
nodesPollInterval: number | null;
logsPollInterval: number | null;
debugPollInterval: number | null;
tab: string;
};
@@ -35,3 +37,17 @@ export function stopLogsPolling(host: PollingHost) {
clearInterval(host.logsPollInterval);
host.logsPollInterval = null;
}
export function startDebugPolling(host: PollingHost) {
if (host.debugPollInterval != null) return;
host.debugPollInterval = window.setInterval(() => {
if (host.tab !== "debug") return;
void loadDebug(host as unknown as ClawdbotApp);
}, 3000);
}
export function stopDebugPolling(host: PollingHost) {
if (host.debugPollInterval == null) return;
clearInterval(host.debugPollInterval);
host.debugPollInterval = null;
}
+91
View File
@@ -0,0 +1,91 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Tab } from "./navigation";
type SettingsHost = Parameters<typeof import("./app-settings").setTabFromRoute>[0];
const createHost = (tab: Tab): SettingsHost => ({
settings: {
gatewayUrl: "",
token: "",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "system",
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,
navCollapsed: false,
navGroupsCollapsed: {},
},
theme: "system",
themeResolved: "dark",
applySessionKey: "main",
sessionKey: "main",
tab,
connected: false,
chatHasAutoScrolled: false,
logsAtBottom: false,
eventLog: [],
eventLogBuffer: [],
basePath: "",
themeMedia: null,
themeMediaHandler: null,
});
describe("setTabFromRoute", () => {
beforeEach(() => {
vi.resetModules();
});
it("starts and stops log polling based on the tab", async () => {
const startLogsPolling = vi.fn();
const stopLogsPolling = vi.fn();
const startDebugPolling = vi.fn();
const stopDebugPolling = vi.fn();
vi.doMock("./app-polling", () => ({
startLogsPolling,
stopLogsPolling,
startDebugPolling,
stopDebugPolling,
}));
const { setTabFromRoute } = await import("./app-settings");
const host = createHost("chat");
setTabFromRoute(host, "logs");
expect(startLogsPolling).toHaveBeenCalledTimes(1);
expect(stopLogsPolling).not.toHaveBeenCalled();
expect(startDebugPolling).not.toHaveBeenCalled();
expect(stopDebugPolling).toHaveBeenCalledTimes(1);
setTabFromRoute(host, "chat");
expect(stopLogsPolling).toHaveBeenCalledTimes(1);
});
it("starts and stops debug polling based on the tab", async () => {
const startLogsPolling = vi.fn();
const stopLogsPolling = vi.fn();
const startDebugPolling = vi.fn();
const stopDebugPolling = vi.fn();
vi.doMock("./app-polling", () => ({
startLogsPolling,
stopLogsPolling,
startDebugPolling,
stopDebugPolling,
}));
const { setTabFromRoute } = await import("./app-settings");
const host = createHost("chat");
setTabFromRoute(host, "debug");
expect(startDebugPolling).toHaveBeenCalledTimes(1);
expect(stopDebugPolling).not.toHaveBeenCalled();
expect(startLogsPolling).not.toHaveBeenCalled();
expect(stopLogsPolling).toHaveBeenCalledTimes(1);
setTabFromRoute(host, "chat");
expect(stopDebugPolling).toHaveBeenCalledTimes(1);
});
});
+7 -1
View File
@@ -14,7 +14,7 @@ import { saveSettings, type UiSettings } from "./storage";
import { resolveTheme, type ResolvedTheme, type ThemeMode } from "./theme";
import { startThemeTransition, type ThemeTransitionContext } from "./theme-transition";
import { scheduleChatScroll, scheduleLogsScroll } from "./app-scroll";
import { startLogsPolling, stopLogsPolling } from "./app-polling";
import { startLogsPolling, stopLogsPolling, startDebugPolling, stopDebugPolling } from "./app-polling";
import { refreshChat } from "./app-chat";
import type { ClawdbotApp } from "./app";
@@ -116,6 +116,9 @@ export function setTab(host: SettingsHost, next: Tab) {
if (next === "logs")
startLogsPolling(host as unknown as Parameters<typeof startLogsPolling>[0]);
else stopLogsPolling(host as unknown as Parameters<typeof stopLogsPolling>[0]);
if (next === "debug")
startDebugPolling(host as unknown as Parameters<typeof startDebugPolling>[0]);
else stopDebugPolling(host as unknown as Parameters<typeof stopDebugPolling>[0]);
void refreshActiveTab(host);
syncUrlWithTab(host, next, false);
}
@@ -261,6 +264,9 @@ export function setTabFromRoute(host: SettingsHost, next: Tab) {
if (next === "logs")
startLogsPolling(host as unknown as Parameters<typeof startLogsPolling>[0]);
else stopLogsPolling(host as unknown as Parameters<typeof stopLogsPolling>[0]);
if (next === "debug")
startDebugPolling(host as unknown as Parameters<typeof startDebugPolling>[0]);
else stopDebugPolling(host as unknown as Parameters<typeof stopDebugPolling>[0]);
if (host.connected) void refreshActiveTab(host);
}
+1
View File
@@ -226,6 +226,7 @@ export class ClawdbotApp extends LitElement {
private chatUserNearBottom = true;
private nodesPollInterval: number | null = null;
private logsPollInterval: number | null = null;
private debugPollInterval: number | null = null;
private logsScrollFrame: number | null = null;
private toolStreamById = new Map<string, ToolStreamEntry>();
private toolStreamOrder: string[] = [];