Compare commits

..
Author SHA1 Message Date
Peter Steinberger f4de123e66 feat: add TUI code block syntax highlighting (#1200) (thanks @vignesh07) 2026-01-19 05:05:36 +00:00
Vignesh Natarajan 5bb7b62f98 fix: make syntax highlighting tests environment-agnostic
Tests now verify structure and content preservation rather than
checking for specific ANSI escape codes, which may not be present
in CI environments without TTY/color support.
2026-01-19 05:00:22 +00:00
Vignesh Natarajan 21d47300af feat(tui): add syntax highlighting for code blocks
Add syntax highlighting to markdown code blocks in the TUI using
cli-highlight with a VS Code Dark-inspired color theme.

Features:
- 191 languages supported via highlight.js
- Auto-detection fallback for unknown languages
- Graceful fallback to plain styling on errors
- VS Code Dark-inspired color palette

Colors:
- Purple: keywords (const, function, if, etc.)
- Teal: built-ins (console, Math, print, etc.)
- Orange: strings
- Green: numbers, comments
- Yellow: function names
- Blue: literals (true, false, null)
- Red: diff deletions
- Light blue: variables, parameters

🤖 AI-assisted (Claude) - fully tested locally
2026-01-19 05:00:22 +00:00
Peter Steinberger de33bc70e7 docs: clarify node_modules guidance 2026-01-19 04:01:36 +00:00
Peter Steinberger 0c8ba6599b fix: add plugin config schema helper 2026-01-19 03:39:36 +00:00
Peter Steinberger d1e9490f95 fix: enforce strict config validation 2026-01-19 03:39:25 +00:00
Peter Steinberger a9fc2ca0ef fix: add git hook setup and stable config hash sorting 2026-01-19 02:02:17 +00:00
Peter Steinberger dd1b08b3e8 fix: add safeguard compaction tool summaries 2026-01-19 01:44:17 +00:00
cpojer af1004ebbd Make tool calls use human language by default. 2026-01-19 01:42:23 +00:00
Peter Steinberger f3516fb316 fix: skip respawn in gateway sigterm test 2026-01-19 01:37:10 +00:00
Peter Steinberger 79d8267413 feat: auto-recreate sandbox containers on config change 2026-01-19 01:35:27 +00:00
Peter Steinberger 99bf65c539 style: apply oxfmt 2026-01-19 01:11:42 +00:00
Peter Steinberger 6a4b5fa4b5 fix: harden windows cli launch 2026-01-19 01:11:39 +00:00
Peter Steinberger 83511c0c09 refactor: consolidate nodes cli error handling 2026-01-19 00:52:31 +00:00
Peter Steinberger 1fec41b3df refactor: share cli runtime error handling 2026-01-19 00:52:31 +00:00
Peter Steinberger c532d161c4 refactor: streamline routed cli setup 2026-01-19 00:52:31 +00:00
Peter Steinberger 989543c9c3 fix: propagate agent run context for subagent announce 2026-01-19 00:45:30 +00:00
Peter Steinberger 953472bf25 feat: add exec pathPrepend config 2026-01-19 00:35:43 +00:00
Peter Steinberger d9384785a3 fix: stabilize ci checks 2026-01-19 00:34:26 +00:00
2f6b5ffdfe Web: trim HTML error bodies in web_fetch (#1193)
* Web: trim HTML error bodies in web_fetch

* fix: trim web_fetch HTML error bodies (#1193) (thanks @sebslight)

---------

Co-authored-by: Sebastian Slight <sbarrios93@gmail.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-19 00:24:16 +00:00
Peter Steinberger 15311c138a macOS: fix onboarding test helper call 2026-01-19 00:19:44 +00:00
Peter Steinberger dec71dbcf1 docs: update README channels + deepwiki badge 2026-01-19 00:17:42 +00:00
Peter Steinberger 5a4482412d fix(plugins): prefer dist plugin-sdk in tests 2026-01-19 00:15:45 +00:00
Peter Steinberger 4cf829608c chore: remove unused program context imports 2026-01-19 00:15:45 +00:00
Peter Steinberger 8de02e6074 test: stabilize sessions_send waits 2026-01-19 00:15:45 +00:00
Peter Steinberger d802844bd6 fix: gate gateway restarts and discord abort reconnects 2026-01-19 00:15:45 +00:00
Peter Steinberger e97bcf4dae refactor(plugins): improve loader resolution 2026-01-19 00:15:44 +00:00
Peter Steinberger dad8e11f1e test: harden gateway mocks and env isolation 2026-01-19 00:15:44 +00:00
Peter Steinberger 50fdd514ae refactor(logging): split config + subsystem imports 2026-01-19 00:15:44 +00:00
Peter Steinberger ee36e12f81 fix: log plugin load errors in gateway 2026-01-19 00:15:24 +00:00
Peter Steinberger 1e5569d56a fix: refine TUI model search rendering 2026-01-19 00:15:16 +00:00
Peter Steinberger 3ce1ee84ac Usage: add cost summaries to /usage + mac menu 2026-01-19 00:05:06 +00:00
Peter Steinberger 1ea3ac0a1d Merge pull request #1197 from chriseidhof/channels
The link should be skills
2026-01-18 23:59:17 +00:00
Peter Steinberger 66b6c9e0e5 chore: document slack bolt import interop 2026-01-18 23:55:36 +00:00
Peter SteinbergerandCorey Henderson b5e99dad1f fix(slack): handle bolt CJS interop (#1191) — thanks @CoreyH
Co-authored-by: Corey Henderson <corey@example.com>
2026-01-18 23:54:50 +00:00
Chris Eidhof af96bac2dd The link should be skills 2026-01-18 22:44:41 +01:00
231 changed files with 4639 additions and 1869 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
src/canvas-host/a2ui/a2ui.bundle.js
+1
View File
@@ -84,6 +84,7 @@
## Agent-Specific Notes
- Vocabulary: "makeup" = "mac app".
- Never edit `node_modules` (global/Homebrew/npm/git installs too). Updates overwrite. Skill notes go in `tools.md` or `AGENTS.md`.
- When working on a GitHub Issue or PR, print the full URL at the end of the task.
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
- Never update the Carbon dependency.
+23 -1
View File
@@ -2,6 +2,25 @@
Docs: https://docs.clawd.bot
## 2026.1.19-1
### Breaking
- **BREAKING:** Reject invalid/unknown config entries and refuse to start the gateway for safety; run `clawdbot doctor --fix` to repair.
### Changes
- Usage: add `/usage cost` summaries and macOS menu cost submenu with daily charting.
- Agents: clarify node_modules read-only guidance in agent instructions.
- TUI: add syntax highlighting for code blocks. (#1200) — thanks @vignesh07.
### Fixes
- Plugins: surface plugin load/register/config errors in gateway logs with plugin/source context.
<<<<<<< Updated upstream
- Agents: propagate accountId into embedded runs so sub-agent announce routing honors the originating account. (#1058)
||||||| Stash base
=======
- Compaction: include tool failure summaries in safeguard compaction to prevent retry loops. (#1084)
>>>>>>> Stashed changes
## 2026.1.18-5
### Changes
@@ -16,9 +35,12 @@ Docs: https://docs.clawd.bot
- macOS: load menu session previews asynchronously so items populate while the menu is open.
- macOS: use label colors for session preview text so previews render in menu subviews.
- macOS: suppress usage error text in the menubar cost view.
- macOS: fix onboarding action link labels for channels vs skills. (#1197) — thanks @chriseidhof.
- Telegram: honor pairing allowlists for native slash commands.
- TUI: highlight model search matches and stabilize search ordering.
- CLI: keep banners on routed commands, restore config guarding outside fast-path routing, and tighten fast-path flag parsing while skipping console capture for extra speed. (#1195) — thanks @gumadeiras.
- Slack: resolve Bolt import interop for Bun + Node. (#1191) — thanks @CoreyH.
- Gateway: require authorized restarts for SIGUSR1 (restart/apply/update) so config gating can't be bypassed.
- Discord: stop reconnecting the gateway after aborts to prevent duplicate listeners.
## 2026.1.18-4
+6 -5
View File
@@ -11,12 +11,13 @@
<p align="center">
<a href="https://github.com/clawdbot/clawdbot/actions/workflows/ci.yml?branch=main"><img src="https://img.shields.io/github/actions/workflow/status/clawdbot/clawdbot/ci.yml?branch=main&style=for-the-badge" alt="CI status"></a>
<a href="https://github.com/clawdbot/clawdbot/releases"><img src="https://img.shields.io/github/v/release/clawdbot/clawdbot?include_prereleases&style=for-the-badge" alt="GitHub release"></a>
<a href="https://deepwiki.com/clawdbot/clawdbot"><img src="https://img.shields.io/badge/DeepWiki-clawdbot-111111?style=for-the-badge" alt="DeepWiki"></a>
<a href="https://discord.gg/clawd"><img src="https://img.shields.io/discord/1456350064065904867?label=Discord&logo=discord&logoColor=white&color=5865F2&style=for-the-badge" alt="Discord"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
</p>
**Clawdbot** is a *personal AI assistant* you run on your own devices.
It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat), can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat), plus extension channels like BlueBubbles, Matrix, Zalo, and Zalo Personal. It can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
@@ -64,7 +65,7 @@ clawdbot gateway --port 18789 --verbose
# Send a message
clawdbot message send --to +1234567890 --message "Hello from Clawdbot"
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Slack/Discord/Microsoft Teams)
# Talk to the assistant (optionally deliver back to any connected channel: WhatsApp/Telegram/Slack/Discord/Signal/iMessage/BlueBubbles/Microsoft Teams/Matrix/Zalo/Zalo Personal/WebChat)
clawdbot agent --message "Ship checklist" --thinking high
```
@@ -106,7 +107,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
## Highlights
- **[Local-first Gateway](https://docs.clawd.bot/gateway)** — single control plane for sessions, channels, tools, and events.
- **[Multi-channel inbox](https://docs.clawd.bot/channels)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat, macOS, iOS/Android.
- **[Multi-channel inbox](https://docs.clawd.bot/channels)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, BlueBubbles, Microsoft Teams, Matrix, Zalo, Zalo Personal, WebChat, macOS, iOS/Android.
- **[Multi-agent routing](https://docs.clawd.bot/gateway/configuration)** — route inbound channels/accounts/peers to isolated agents (workspaces + per-agent sessions).
- **[Voice Wake](https://docs.clawd.bot/nodes/voicewake) + [Talk Mode](https://docs.clawd.bot/nodes/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs.
- **[Live Canvas](https://docs.clawd.bot/platforms/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui).
@@ -128,7 +129,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
- [Media pipeline](https://docs.clawd.bot/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.clawd.bot/nodes/audio).
### Channels
- [Channels](https://docs.clawd.bot/channels): [WhatsApp](https://docs.clawd.bot/channels/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/channels/telegram) (grammY), [Slack](https://docs.clawd.bot/channels/slack) (Bolt), [Discord](https://docs.clawd.bot/channels/discord) (discord.js), [Signal](https://docs.clawd.bot/channels/signal) (signal-cli), [iMessage](https://docs.clawd.bot/channels/imessage) (imsg), [Microsoft Teams](https://docs.clawd.bot/channels/msteams) (Bot Framework), [WebChat](https://docs.clawd.bot/web/webchat).
- [Channels](https://docs.clawd.bot/channels): [WhatsApp](https://docs.clawd.bot/channels/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/channels/telegram) (grammY), [Slack](https://docs.clawd.bot/channels/slack) (Bolt), [Discord](https://docs.clawd.bot/channels/discord) (discord.js), [Signal](https://docs.clawd.bot/channels/signal) (signal-cli), [iMessage](https://docs.clawd.bot/channels/imessage) (imsg), [BlueBubbles](https://docs.clawd.bot/channels/bluebubbles) (extension), [Microsoft Teams](https://docs.clawd.bot/channels/msteams) (extension), [Matrix](https://docs.clawd.bot/channels/matrix) (extension), [Zalo](https://docs.clawd.bot/channels/zalo) (extension), [Zalo Personal](https://docs.clawd.bot/channels/zalouser) (extension), [WebChat](https://docs.clawd.bot/web/webchat).
- [Group routing](https://docs.clawd.bot/concepts/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.clawd.bot/channels).
### Apps + nodes
@@ -159,7 +160,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
## How it works (short)
```
WhatsApp / Telegram / Slack / Discord / Signal / iMessage / Microsoft Teams / WebChat
WhatsApp / Telegram / Slack / Discord / Signal / iMessage / BlueBubbles / Microsoft Teams / Matrix / Zalo / Zalo Personal / WebChat
│
▼
┌───────────────────────────────┐
@@ -0,0 +1,99 @@
import Charts
import SwiftUI
struct CostUsageHistoryMenuView: View {
let summary: GatewayCostUsageSummary
let width: CGFloat
var body: some View {
VStack(alignment: .leading, spacing: 10) {
self.header
self.chart
self.footer
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.frame(width: max(1, self.width), alignment: .leading)
}
private var header: some View {
let todayKey = CostUsageMenuDateParser.format(Date())
let todayEntry = self.summary.daily.first { $0.date == todayKey }
let todayCost = CostUsageFormatting.formatUsd(todayEntry?.totalCost) ?? "n/a"
let totalCost = CostUsageFormatting.formatUsd(self.summary.totals.totalCost) ?? "n/a"
return HStack(alignment: .firstTextBaseline, spacing: 12) {
VStack(alignment: .leading, spacing: 2) {
Text("Today")
.font(.caption2)
.foregroundStyle(.secondary)
Text(todayCost)
.font(.system(size: 14, weight: .semibold))
}
VStack(alignment: .leading, spacing: 2) {
Text("Last \(self.summary.days)d")
.font(.caption2)
.foregroundStyle(.secondary)
Text(totalCost)
.font(.system(size: 14, weight: .semibold))
}
Spacer()
}
}
private var chart: some View {
let entries = self.summary.daily.compactMap { entry -> (Date, Double)? in
guard let date = CostUsageMenuDateParser.parse(entry.date) else { return nil }
return (date, entry.totalCost)
}
return Chart(entries, id: \.0) { entry in
BarMark(
x: .value("Day", entry.0),
y: .value("Cost", entry.1))
.foregroundStyle(Color.accentColor)
.cornerRadius(3)
}
.chartXAxis {
AxisMarks(values: .stride(by: .day, count: 7)) {
AxisGridLine().foregroundStyle(.clear)
AxisValueLabel(format: .dateTime.month().day())
}
}
.chartYAxis {
AxisMarks(position: .leading) {
AxisGridLine()
AxisValueLabel()
}
}
.frame(height: 110)
}
private var footer: some View {
if self.summary.totals.missingCostEntries == 0 {
return AnyView(EmptyView())
}
return AnyView(
Text("Partial: \(self.summary.totals.missingCostEntries) entries missing cost")
.font(.caption2)
.foregroundStyle(.secondary))
}
}
private enum CostUsageMenuDateParser {
static let formatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone.current
return formatter
}()
static func parse(_ value: String) -> Date? {
self.formatter.date(from: value)
}
static func format(_ date: Date) -> String {
self.formatter.string(from: date)
}
}
@@ -27,6 +27,10 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
private var cachedUsageErrorText: String?
private var usageCacheUpdatedAt: Date?
private let usageRefreshIntervalSeconds: TimeInterval = 30
private var cachedCostSummary: GatewayCostUsageSummary?
private var cachedCostErrorText: String?
private var costCacheUpdatedAt: Date?
private let costRefreshIntervalSeconds: TimeInterval = 45
private let nodesStore = NodesStore.shared
#if DEBUG
private var testControlChannelConnected: Bool?
@@ -64,6 +68,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
guard let self else { return }
await self.refreshCache(force: forceRefresh)
await self.refreshUsageCache(force: forceRefresh)
await self.refreshCostUsageCache(force: forceRefresh)
await MainActor.run {
guard self.isMenuOpen else { return }
self.inject(into: menu)
@@ -200,6 +205,7 @@ extension MenuSessionsInjector {
}
cursor = self.insertUsageSection(into: menu, at: cursor, width: width)
cursor = self.insertCostUsageSection(into: menu, at: cursor, width: width)
DispatchQueue.main.async { [weak self, weak headerView] in
guard let self, let headerView else { return }
@@ -344,6 +350,28 @@ extension MenuSessionsInjector {
return cursor
}
private func insertCostUsageSection(into menu: NSMenu, at cursor: Int, width: CGFloat) -> Int {
guard self.isControlChannelConnected else { return cursor }
guard let submenu = self.buildCostUsageSubmenu(width: width) else { return cursor }
var cursor = cursor
if cursor > 0, !menu.items[cursor - 1].isSeparatorItem {
let separator = NSMenuItem.separator()
separator.tag = self.tag
menu.insertItem(separator, at: cursor)
cursor += 1
}
let item = NSMenuItem(title: "Usage cost (30 days)", action: nil, keyEquivalent: "")
item.tag = self.tag
item.isEnabled = true
item.image = NSImage(systemSymbolName: "chart.bar.xaxis", accessibilityDescription: nil)
item.submenu = submenu
menu.insertItem(item, at: cursor)
cursor += 1
return cursor
}
private var selectedUsageProviderId: String? {
guard let model = self.cachedSnapshot?.defaults.model.nonEmpty else { return nil }
let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -393,6 +421,36 @@ extension MenuSessionsInjector {
}
}
private func buildCostUsageSubmenu(width: CGFloat) -> NSMenu? {
if let error = self.cachedCostErrorText, !error.isEmpty, self.cachedCostSummary == nil {
let menu = NSMenu()
let item = NSMenuItem(title: error, action: nil, keyEquivalent: "")
item.isEnabled = false
menu.addItem(item)
return menu
}
guard let summary = self.cachedCostSummary else { return nil }
guard !summary.daily.isEmpty else { return nil }
let menu = NSMenu()
menu.delegate = self
let chartView = CostUsageHistoryMenuView(summary: summary, width: width)
let hosting = NSHostingView(rootView: AnyView(chartView))
let controller = NSHostingController(rootView: AnyView(chartView))
let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude))
hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height))
let chartItem = NSMenuItem()
chartItem.view = hosting
chartItem.isEnabled = false
chartItem.representedObject = "costUsageChart"
menu.addItem(chartItem)
return menu
}
private func gatewayEntry() -> NodeInfo? {
let mode = AppStateStore.shared.connectionMode
let isConnected = self.isControlChannelConnected
@@ -581,6 +639,31 @@ extension MenuSessionsInjector {
self.usageCacheUpdatedAt = Date()
}
private func refreshCostUsageCache(force: Bool) async {
if !force,
let updated = self.costCacheUpdatedAt,
Date().timeIntervalSince(updated) < self.costRefreshIntervalSeconds
{
return
}
guard self.isControlChannelConnected else {
self.cachedCostSummary = nil
self.cachedCostErrorText = nil
self.costCacheUpdatedAt = Date()
return
}
do {
self.cachedCostSummary = try await CostUsageLoader.loadSummary()
self.cachedCostErrorText = nil
} catch {
self.cachedCostSummary = nil
self.cachedCostErrorText = self.compactUsageError(error)
}
self.costCacheUpdatedAt = Date()
}
private func compactUsageError(_ error: Error) -> String {
let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
if message.isEmpty { return "Usage unavailable" }
@@ -78,6 +78,7 @@ extension OnboardingView {
title: "Action",
subtitle: "Action subtitle",
systemImage: "gearshape",
buttonTitle: "Action",
action: {})
_ = view.gatewaySubtitle(for: gateway)
_ = view.isSelectedGateway(gateway)
@@ -0,0 +1,60 @@
import Foundation
struct GatewayCostUsageTotals: Codable {
let input: Int
let output: Int
let cacheRead: Int
let cacheWrite: Int
let totalTokens: Int
let totalCost: Double
let missingCostEntries: Int
}
struct GatewayCostUsageDay: Codable {
let date: String
let input: Int
let output: Int
let cacheRead: Int
let cacheWrite: Int
let totalTokens: Int
let totalCost: Double
let missingCostEntries: Int
}
struct GatewayCostUsageSummary: Codable {
let updatedAt: Double
let days: Int
let daily: [GatewayCostUsageDay]
let totals: GatewayCostUsageTotals
}
enum CostUsageFormatting {
static func formatUsd(_ value: Double?) -> String? {
guard let value, value.isFinite else { return nil }
if value >= 1 { return String(format: "$%.2f", value) }
if value >= 0.01 { return String(format: "$%.2f", value) }
return String(format: "$%.4f", value)
}
static func formatTokenCount(_ value: Int?) -> String? {
guard let value else { return nil }
let safe = max(0, value)
if safe >= 1_000_000 { return String(format: "%.1fm", Double(safe) / 1_000_000.0) }
if safe >= 1000 { return safe >= 10000
? String(format: "%.0fk", Double(safe) / 1000.0)
: String(format: "%.1fk", Double(safe) / 1000.0)
}
return String(safe)
}
}
@MainActor
enum CostUsageLoader {
static func loadSummary() async throws -> GatewayCostUsageSummary {
let data = try await ControlChannel.shared.request(
method: "usage.cost",
params: nil,
timeoutMs: 7000)
return try JSONDecoder().decode(GatewayCostUsageSummary.self, from: data)
}
}
@@ -424,14 +424,18 @@ public struct PollParams: Codable, Sendable {
public struct AgentParams: Codable, Sendable {
public let message: String
public let agentid: String?
public let to: String?
public let replyto: String?
public let sessionid: String?
public let sessionkey: String?
public let thinking: String?
public let deliver: Bool?
public let attachments: [AnyCodable]?
public let channel: String?
public let replychannel: String?
public let accountid: String?
public let replyaccountid: String?
public let timeout: Int?
public let lane: String?
public let extrasystemprompt: String?
@@ -441,14 +445,18 @@ public struct AgentParams: Codable, Sendable {
public init(
message: String,
agentid: String?,
to: String?,
replyto: String?,
sessionid: String?,
sessionkey: String?,
thinking: String?,
deliver: Bool?,
attachments: [AnyCodable]?,
channel: String?,
replychannel: String?,
accountid: String?,
replyaccountid: String?,
timeout: Int?,
lane: String?,
extrasystemprompt: String?,
@@ -457,14 +465,18 @@ public struct AgentParams: Codable, Sendable {
spawnedby: String?
) {
self.message = message
self.agentid = agentid
self.to = to
self.replyto = replyto
self.sessionid = sessionid
self.sessionkey = sessionkey
self.thinking = thinking
self.deliver = deliver
self.attachments = attachments
self.channel = channel
self.replychannel = replychannel
self.accountid = accountid
self.replyaccountid = replyaccountid
self.timeout = timeout
self.lane = lane
self.extrasystemprompt = extrasystemprompt
@@ -474,14 +486,18 @@ public struct AgentParams: Codable, Sendable {
}
private enum CodingKeys: String, CodingKey {
case message
case agentid = "agentId"
case to
case replyto = "replyTo"
case sessionid = "sessionId"
case sessionkey = "sessionKey"
case thinking
case deliver
case attachments
case channel
case replychannel = "replyChannel"
case accountid = "accountId"
case replyaccountid = "replyAccountId"
case timeout
case lane
case extrasystemprompt = "extraSystemPrompt"
+1 -1
View File
@@ -28,7 +28,7 @@ clawdbot gateway
Notes:
- By default, the Gateway refuses to start unless `gateway.mode=local` is set in `~/.clawdbot/clawdbot.json`. Use `--allow-unconfigured` for ad-hoc/dev runs.
- Binding beyond loopback without auth is blocked (safety guardrail).
- `SIGUSR1` triggers an in-process restart (useful without a supervisor).
- `SIGUSR1` triggers an in-process restart when authorized (enable `commands.restart` or use the gateway tool/config apply/update).
- `SIGINT`/`SIGTERM` handlers stop the gateway process, but they don’t restore any custom terminal state. If you wrap the CLI with a TUI or raw-mode input, restore the terminal before exit.
### Options
+9
View File
@@ -89,6 +89,15 @@ clawdbot sandbox recreate --all
clawdbot sandbox recreate --all
```
### After changing setupCommand
```bash
clawdbot sandbox recreate --all
# or just one agent:
clawdbot sandbox recreate --agent family
```
### For a specific agent only
```bash
+3
View File
@@ -293,6 +293,9 @@ Starting with v2026.1.6, each agent can have its own sandbox and tool restrictio
}
```
Note: `setupCommand` lives under `sandbox.docker` and runs once on container creation.
Per-agent `sandbox.docker.*` overrides are ignored when the resolved scope is `"shared"`.
**Benefits:**
- **Security isolation**: Restrict tools for untrusted agents
- **Resource control**: Sandbox specific agents while keeping others on host
+1
View File
@@ -13,6 +13,7 @@ read_when:
## Where it shows up
- `/status` in chats: emoji‑rich status card with session tokens + estimated cost (API key only). Provider usage shows for the **current model provider** when available.
- `/usage off|tokens|full` in chats: per-response usage footer (OAuth shows tokens only).
- `/usage cost` in chats: local cost summary aggregated from Clawdbot session logs.
- CLI: `clawdbot status --usage` prints a full per-provider breakdown.
- CLI: `clawdbot channels list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip).
- macOS menu bar: “Usage” section under Context (only if available).
+16
View File
@@ -17,6 +17,19 @@ If the file is missing, Clawdbot uses safe-ish defaults (embedded Pi agent + per
> **New to configuration?** Check out the [Configuration Examples](/gateway/configuration-examples) guide for complete examples with detailed explanations!
## Strict config validation
Clawdbot only accepts configurations that fully match the schema.
Unknown keys, malformed types, or invalid values cause the Gateway to **refuse to start** for safety.
When validation fails:
- The Gateway does not boot.
- Only diagnostic commands are allowed (for example: `clawdbot doctor`, `clawdbot logs`, `clawdbot health`, `clawdbot status`, `clawdbot service`, `clawdbot help`).
- Run `clawdbot doctor` to see the exact issues.
- Run `clawdbot doctor --fix` (or `--yes`) to apply migrations/repairs.
Doctor never writes changes unless you explicitly opt into `--fix`/`--yes`.
## Schema + UI hints
The Gateway exposes a JSON Schema representation of the config via `config.schema` for UI editors.
@@ -1990,6 +2003,9 @@ cross-session isolation. Use `scope: "session"` for per-session isolation.
Legacy: `perSession` is still supported (`true` → `scope: "session"`,
`false` → `scope: "shared"`).
`setupCommand` runs **once** after the container is created (inside the container via `sh -lc`).
For package installs, ensure network egress, a writable root FS, and a root user.
```json5
{
agents: {
+1 -1
View File
@@ -34,7 +34,7 @@ pnpm gateway:watch
- Pass `--verbose` to mirror debug logging (handshakes, req/res, events) from the log file into stdio when troubleshooting.
- `--force` uses `lsof` to find listeners on the chosen port, sends SIGTERM, logs what it killed, then starts the gateway (fails fast if `lsof` is missing).
- If you run under a supervisor (launchd/systemd/mac app child-process mode), a stop/restart typically sends **SIGTERM**; older builds may surface this as `pnpm` `ELIFECYCLE` exit code **143** (SIGTERM), which is a normal shutdown, not a crash.
- **SIGUSR1** triggers an in-process restart (no external supervisor required). This is what the `gateway` agent tool uses.
- **SIGUSR1** triggers an in-process restart when authorized (gateway tool/config apply/update, or enable `commands.restart` for manual restarts).
- Gateway auth: set `gateway.auth.mode=token` + `gateway.auth.token` (or pass `--token <value>` / `CLAWDBOT_GATEWAY_TOKEN`) to require clients to send `connect.params.auth.token`.
- The wizard now generates a token by default, even on loopback.
- Port precedence: `--port` > `CLAWDBOT_GATEWAY_PORT` > `gateway.port` > default `18789`.
+14
View File
@@ -116,6 +116,20 @@ Override with `agents.defaults.sandbox.docker.network`.
Docker installs and the containerized gateway live here:
[Docker](/install/docker)
## setupCommand (one-time container setup)
`setupCommand` runs **once** after the sandbox container is created (not on every run).
It executes inside the container via `sh -lc`.
Paths:
- Global: `agents.defaults.sandbox.docker.setupCommand`
- Per-agent: `agents.list[].sandbox.docker.setupCommand`
Common pitfalls:
- Default `docker.network` is `"none"` (no egress), so package installs will fail.
- `readOnlyRoot: true` prevents writes; set `readOnlyRoot: false` or bake a custom image.
- `user` must be root for package installs (omit `user` or set `user: "0:0"`).
## Tool policy + escape hatches
Tool allow/deny policies still apply before sandbox rules. If a tool is denied
globally or per-agent, sandboxing doesn’t bring it back.
+16
View File
@@ -326,6 +326,22 @@ Clawdbot keeps conversation history in memory.
## Common troubleshooting
### “Gateway won’t start — configuration invalid”
Clawdbot now refuses to start when the config contains unknown keys, malformed values, or invalid types.
This is intentional for safety.
Fix it with Doctor:
```bash
clawdbot doctor
clawdbot doctor --fix
```
Notes:
- `clawdbot doctor` reports every invalid entry.
- `clawdbot doctor --fix` applies migrations/repairs and rewrites the config.
- Diagnostic commands like `clawdbot logs`, `clawdbot health`, `clawdbot status`, and `clawdbot service` still run even if the config is invalid.
### “All models failed” — what should I check first?
- **Credentials** present for the provider(s) being tried (auth profiles + env vars).
+8
View File
@@ -254,6 +254,14 @@ precedence, and troubleshooting.
### Enable sandboxing
If you plan to install packages in `setupCommand`, note:
- Default `docker.network` is `"none"` (no egress).
- `readOnlyRoot: true` blocks package installs.
- `user` must be root for `apt-get` (omit `user` or set `user: "0:0"`).
Clawdbot auto-recreates containers when `setupCommand` (or docker config) changes
unless the container was **recently used** (within ~5 minutes). Hot containers
log a warning with the exact `clawdbot sandbox recreate ...` command.
```json5
{
agents: {
+3
View File
@@ -18,6 +18,9 @@ This allows you to run multiple agents with different security profiles:
- Family/work agents with restricted tools
- Public-facing agents in sandboxes
`setupCommand` belongs under `sandbox.docker` (global or per-agent) and runs once
when the container is created.
Auth is per-agent: each agent reads from its own `agentDir` auth store at:
```
+78
View File
@@ -0,0 +1,78 @@
---
summary: "Strict config validation + doctor-only migrations"
read_when:
- Designing or implementing config validation behavior
- Working on config migrations or doctor workflows
- Handling plugin config schemas or plugin load gating
---
# Strict config validation (doctor-only migrations)
## Goals
- **Reject unknown config keys everywhere** (root + nested).
- **Reject plugin config without a schema**; don’t load that plugin.
- **Remove legacy auto-migration on load**; migrations run via doctor only.
- **Auto-run doctor (dry-run) on startup**; if invalid, block non-diagnostic commands.
## Non-goals
- Backward compatibility on load (legacy keys do not auto-migrate).
- Silent drops of unrecognized keys.
## Strict validation rules
- Config must match the schema exactly at every level.
- Unknown keys are validation errors (no passthrough at root or nested).
- `plugins.entries.<id>.config` must be validated by the plugin’s schema.
- If a plugin lacks a schema, **reject plugin load** and surface a clear error.
## Plugin schema enforcement
- Each plugin provides a strict schema for its config (no passthrough).
- Plugin load flow:
1) Resolve plugin schema by plugin id.
2) Validate config against the schema.
3) If missing schema or invalid config: block plugin load, record error.
- Error message includes:
- Plugin id
- Reason (missing schema / invalid config)
- Path(s) that failed validation
## Doctor flow
- Doctor runs **every time** config is loaded (dry-run by default).
- If config invalid:
- Print a summary + actionable errors.
- Instruct: `clawdbot doctor --fix`.
- `clawdbot doctor --fix`:
- Applies migrations.
- Removes unknown keys.
- Writes updated config.
## Command gating (when config is invalid)
Allowed (diagnostic-only):
- `clawdbot doctor`
- `clawdbot logs`
- `clawdbot health`
- `clawdbot help`
- `clawdbot status`
- `clawdbot service`
Everything else must hard-fail with: “Config invalid. Run `clawdbot doctor --fix`.”
## Error UX format
- Single summary header.
- Grouped sections:
- Unknown keys (full paths)
- Legacy keys / migrations needed
- Plugin load failures (plugin id + reason + path)
## Implementation touchpoints
- `src/config/zod-schema.ts`: remove root passthrough; strict objects everywhere.
- `src/config/zod-schema.providers.ts`: ensure strict channel schemas.
- `src/config/validation.ts`: fail on unknown keys; do not apply legacy migrations.
- `src/config/io.ts`: remove legacy auto-migrations; always run doctor dry-run.
- `src/config/legacy*.ts`: move usage to doctor only.
- `src/plugins/*`: add schema registry + gating.
- CLI command gating in `src/cli`.
## Tests
- Unknown key rejection (root + nested).
- Plugin missing schema → plugin load blocked with clear error.
- Invalid config → gateway startup blocked except diagnostic commands.
- Doctor dry-run auto; `doctor --fix` writes corrected config.
+5
View File
@@ -15,6 +15,11 @@ Use these when a task is clearly tied to a script; otherwise prefer the CLI.
- Prefer CLI surfaces when they exist (example: auth monitoring uses `clawdbot models status --check`).
- Assume scripts are host‑specific; read them before running on a new machine.
## Git hooks
- `scripts/setup-git-hooks.js`: best-effort setup for `core.hooksPath` when inside a git repo.
- `scripts/format-staged.js`: pre-commit formatter for staged `src/` and `test/` files.
## Auth monitoring scripts
Auth monitoring scripts are documented here:
+1
View File
@@ -45,6 +45,7 @@ Use these in chat:
- `/usage off|tokens|full` → appends a **per-response usage footer** to every reply.
- Persists per session (stored as `responseUsage`).
- OAuth auth **hides cost** (tokens only).
- `/usage cost` → shows a local cost summary from Clawdbot session logs.
Other surfaces:
+22
View File
@@ -40,6 +40,28 @@ Notes:
- `tools.exec.security` (default: `deny`)
- `tools.exec.ask` (default: `on-miss`)
- `tools.exec.node` (default: unset)
- `tools.exec.pathPrepend`: list of directories to prepend to `PATH` for exec runs.
Example:
```json5
{
tools: {
exec: {
pathPrepend: ["~/bin", "/opt/oss/bin"]
}
}
}
```
### PATH handling
- `host=gateway`: uses the Gateway process `PATH`. Daemons install a minimal `PATH`:
- macOS: `/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin`, `/bin`
- Linux: `/usr/local/bin`, `/usr/bin`, `/bin`
- `host=sandbox`: runs `sh -lc` (login shell) inside the container, so `/etc/profile` may reset `PATH`.
Clawdbot prepends `env.PATH` after profile sourcing; `tools.exec.pathPrepend` applies here too.
- `host=node`: only env overrides you pass are sent to the node. `tools.exec.pathPrepend` only applies
if the exec call already sets `env.PATH`.
Per-agent node binding (use the agent list index in config):
+1 -1
View File
@@ -356,7 +356,7 @@ Notes:
Restart or apply updates to the running Gateway process (in-place).
Core actions:
- `restart` (sends `SIGUSR1` to the current process; `clawdbot gateway` restart in-place)
- `restart` (authorizes + sends `SIGUSR1` for in-process restart; `clawdbot gateway` restart in-place)
- `config.get` / `config.schema`
- `config.apply` (validate + write config + restart + wake)
- `update.run` (run update + restart + wake)
+2
View File
@@ -111,6 +111,8 @@ Note on sandboxing:
- `requires.bins` is checked on the **host** at skill load time.
- If an agent is sandboxed, the binary must also exist **inside the container**.
Install it via `agents.defaults.sandbox.docker.setupCommand` (or a custom image).
`setupCommand` runs once after the container is created.
Package installs also require network egress, a writable root FS, and a root user in the sandbox.
Example: the `summarize` skill (`skills/summarize/SKILL.md`) needs the `summarize` CLI
in the sandbox container to run there.
+2 -2
View File
@@ -64,7 +64,7 @@ Text + native (when enabled):
- `/subagents list|stop|log|info|send` (inspect, stop, log, or message sub-agent runs for the current session)
- `/config show|get|set|unset` (persist config to disk, owner-only; requires `commands.config: true`)
- `/debug show|set|unset|reset` (runtime overrides, owner-only; requires `commands.debug: true`)
- `/usage off|tokens|full` (per-response usage footer)
- `/usage off|tokens|full|cost` (per-response usage footer or local cost summary)
- `/stop`
- `/restart`
- `/dock-telegram` (alias: `/dock_telegram`) (switch replies to Telegram)
@@ -91,7 +91,7 @@ Text-only:
Notes:
- Commands accept an optional `:` between the command and args (e.g. `/think: high`, `/send: on`, `/help:`).
- For full provider usage breakdown, use `clawdbot status --usage`.
- `/usage` controls the per-response usage footer. It only shows dollar cost when the model uses an API key (OAuth hides cost).
- `/usage` controls the per-response usage footer; `/usage cost` prints a local cost summary from Clawdbot session logs.
- `/restart` is disabled by default; set `commands.restart: true` to enable it.
- `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use.
- `/reasoning` (and `/verbose`) are risky in group settings: they may reveal internal reasoning or tool output you did not intend to expose. Prefer leaving them off, especially in group chats.
+2
View File
@@ -1,4 +1,5 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { bluebubblesPlugin } from "./src/channel.js";
import { handleBlueBubblesWebhookRequest } from "./src/monitor.js";
@@ -8,6 +9,7 @@ const plugin = {
id: "bluebubbles",
name: "BlueBubbles",
description: "BlueBubbles channel plugin (macOS app)",
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) {
setBlueBubblesRuntime(api.runtime);
api.registerChannel({ plugin: bluebubblesPlugin });
+3
View File
@@ -1,3 +1,5 @@
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
const DEFAULT_BASE_URL = "http://localhost:3000/v1";
const DEFAULT_API_KEY = "n/a";
const DEFAULT_CONTEXT_WINDOW = 128_000;
@@ -61,6 +63,7 @@ const copilotProxyPlugin = {
id: "copilot-proxy",
name: "Copilot Proxy",
description: "Local Copilot Proxy (VS Code LM) provider plugin",
configSchema: emptyPluginConfigSchema(),
register(api) {
api.registerProvider({
id: "copilot-proxy",
+2
View File
@@ -1,4 +1,5 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { discordPlugin } from "./src/channel.js";
import { setDiscordRuntime } from "./src/runtime.js";
@@ -7,6 +8,7 @@ const plugin = {
id: "discord",
name: "Discord",
description: "Discord channel plugin",
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) {
setDiscordRuntime(api.runtime);
api.registerChannel({ plugin: discordPlugin });
@@ -1,6 +1,7 @@
import { createHash, randomBytes } from "node:crypto";
import { readFileSync } from "node:fs";
import { createServer } from "node:http";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
// OAuth constants - decoded from pi-ai's base64 encoded values to stay in sync
const decode = (s: string) => Buffer.from(s, "base64").toString();
@@ -360,6 +361,7 @@ const antigravityPlugin = {
id: "google-antigravity-auth",
name: "Google Antigravity Auth",
description: "OAuth flow for Google Antigravity (Cloud Code Assist)",
configSchema: emptyPluginConfigSchema(),
register(api) {
api.registerProvider({
id: "google-antigravity",
@@ -1,3 +1,5 @@
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { loginGeminiCliOAuth } from "./oauth.js";
const PROVIDER_ID = "google-gemini-cli";
@@ -14,6 +16,7 @@ const geminiCliPlugin = {
id: "google-gemini-cli-auth",
name: "Google Gemini CLI Auth",
description: "OAuth flow for Gemini CLI (Google Code Assist)",
configSchema: emptyPluginConfigSchema(),
register(api) {
api.registerProvider({
id: PROVIDER_ID,
+2
View File
@@ -1,4 +1,5 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { imessagePlugin } from "./src/channel.js";
import { setIMessageRuntime } from "./src/runtime.js";
@@ -7,6 +8,7 @@ const plugin = {
id: "imessage",
name: "iMessage",
description: "iMessage channel plugin",
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) {
setIMessageRuntime(api.runtime);
api.registerChannel({ plugin: imessagePlugin });
+2
View File
@@ -1,4 +1,5 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { matrixPlugin } from "./src/channel.js";
import { setMatrixRuntime } from "./src/runtime.js";
@@ -7,6 +8,7 @@ const plugin = {
id: "matrix",
name: "Matrix",
description: "Matrix channel plugin (matrix-js-sdk)",
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) {
setMatrixRuntime(api.runtime);
api.registerChannel({ plugin: matrixPlugin });
+2
View File
@@ -1,10 +1,12 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
const memoryCorePlugin = {
id: "memory-core",
name: "Memory (Core)",
description: "File-backed memory search tools and CLI",
kind: "memory",
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) {
api.registerTool(
(ctx) => {
+12
View File
@@ -24,6 +24,16 @@ const EMBEDDING_DIMENSIONS: Record<string, number> = {
"text-embedding-3-large": 3072,
};
function assertAllowedKeys(
value: Record<string, unknown>,
allowed: string[],
label: string,
) {
const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
if (unknown.length === 0) return;
throw new Error(`${label} has unknown keys: ${unknown.join(", ")}`);
}
export function vectorDimsForModel(model: string): number {
const dims = EMBEDDING_DIMENSIONS[model];
if (!dims) {
@@ -54,11 +64,13 @@ export const memoryConfigSchema = {
throw new Error("memory config required");
}
const cfg = value as Record<string, unknown>;
assertAllowedKeys(cfg, ["embedding", "dbPath", "autoCapture", "autoRecall"], "memory config");
const embedding = cfg.embedding as Record<string, unknown> | undefined;
if (!embedding || typeof embedding.apiKey !== "string") {
throw new Error("embedding.apiKey is required");
}
assertAllowedKeys(embedding, ["apiKey", "model"], "embedding config");
const model = resolveEmbeddingModel(embedding);
+2
View File
@@ -1,4 +1,5 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { msteamsPlugin } from "./src/channel.js";
import { setMSTeamsRuntime } from "./src/runtime.js";
@@ -7,6 +8,7 @@ const plugin = {
id: "msteams",
name: "Microsoft Teams",
description: "Microsoft Teams channel plugin (Bot Framework)",
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) {
setMSTeamsRuntime(api.runtime);
api.registerChannel({ plugin: msteamsPlugin });
+3
View File
@@ -1,3 +1,5 @@
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { loginQwenPortalOAuth } from "./oauth.js";
const PROVIDER_ID = "qwen-portal";
@@ -30,6 +32,7 @@ const qwenPortalPlugin = {
id: "qwen-portal-auth",
name: "Qwen OAuth",
description: "OAuth flow for Qwen (free-tier) models",
configSchema: emptyPluginConfigSchema(),
register(api) {
api.registerProvider({
id: PROVIDER_ID,
+2
View File
@@ -1,4 +1,5 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { signalPlugin } from "./src/channel.js";
import { setSignalRuntime } from "./src/runtime.js";
@@ -7,6 +8,7 @@ const plugin = {
id: "signal",
name: "Signal",
description: "Signal channel plugin",
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) {
setSignalRuntime(api.runtime);
api.registerChannel({ plugin: signalPlugin });
+2
View File
@@ -1,4 +1,5 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { slackPlugin } from "./src/channel.js";
import { setSlackRuntime } from "./src/runtime.js";
@@ -7,6 +8,7 @@ const plugin = {
id: "slack",
name: "Slack",
description: "Slack channel plugin",
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) {
setSlackRuntime(api.runtime);
api.registerChannel({ plugin: slackPlugin });
+2
View File
@@ -1,4 +1,5 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { telegramPlugin } from "./src/channel.js";
import { setTelegramRuntime } from "./src/runtime.js";
@@ -7,6 +8,7 @@ const plugin = {
id: "telegram",
name: "Telegram",
description: "Telegram channel plugin",
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) {
setTelegramRuntime(api.runtime);
api.registerChannel({ plugin: telegramPlugin });
+23 -8
View File
@@ -35,30 +35,36 @@ export type InboundPolicy = z.infer<typeof InboundPolicySchema>;
// Provider-Specific Configuration
// -----------------------------------------------------------------------------
export const TelnyxConfigSchema = z.object({
export const TelnyxConfigSchema = z
.object({
/** Telnyx API v2 key */
apiKey: z.string().min(1).optional(),
/** Telnyx connection ID (from Call Control app) */
connectionId: z.string().min(1).optional(),
/** Public key for webhook signature verification */
publicKey: z.string().min(1).optional(),
});
})
.strict();
export type TelnyxConfig = z.infer<typeof TelnyxConfigSchema>;
export const TwilioConfigSchema = z.object({
export const TwilioConfigSchema = z
.object({
/** Twilio Account SID */
accountSid: z.string().min(1).optional(),
/** Twilio Auth Token */
authToken: z.string().min(1).optional(),
});
})
.strict();
export type TwilioConfig = z.infer<typeof TwilioConfigSchema>;
export const PlivoConfigSchema = z.object({
export const PlivoConfigSchema = z
.object({
/** Plivo Auth ID (starts with MA/SA) */
authId: z.string().min(1).optional(),
/** Plivo Auth Token */
authToken: z.string().min(1).optional(),
});
})
.strict();
export type PlivoConfig = z.infer<typeof PlivoConfigSchema>;
// -----------------------------------------------------------------------------
@@ -72,6 +78,7 @@ export const SttConfigSchema = z
/** Whisper model to use */
model: z.string().min(1).default("whisper-1"),
})
.strict()
.default({ provider: "openai", model: "whisper-1" });
export type SttConfig = z.infer<typeof SttConfigSchema>;
@@ -97,6 +104,7 @@ export const TtsConfigSchema = z
*/
instructions: z.string().optional(),
})
.strict()
.default({ provider: "openai", model: "gpt-4o-mini-tts", voice: "coral" });
export type TtsConfig = z.infer<typeof TtsConfigSchema>;
@@ -113,6 +121,7 @@ export const VoiceCallServeConfigSchema = z
/** Webhook path */
path: z.string().min(1).default("/voice/webhook"),
})
.strict()
.default({ port: 3334, bind: "127.0.0.1", path: "/voice/webhook" });
export type VoiceCallServeConfig = z.infer<typeof VoiceCallServeConfigSchema>;
@@ -128,6 +137,7 @@ export const VoiceCallTailscaleConfigSchema = z
/** Path for Tailscale serve/funnel (should usually match serve.path) */
path: z.string().min(1).default("/voice/webhook"),
})
.strict()
.default({ mode: "off", path: "/voice/webhook" });
export type VoiceCallTailscaleConfig = z.infer<
typeof VoiceCallTailscaleConfigSchema
@@ -161,6 +171,7 @@ export const VoiceCallTunnelConfigSchema = z
*/
allowNgrokFreeTier: z.boolean().default(true),
})
.strict()
.default({ provider: "none", allowNgrokFreeTier: true });
export type VoiceCallTunnelConfig = z.infer<typeof VoiceCallTunnelConfigSchema>;
@@ -183,6 +194,7 @@ export const OutboundConfigSchema = z
/** Seconds to wait after TTS before auto-hangup in notify mode */
notifyHangupDelaySec: z.number().int().nonnegative().default(3),
})
.strict()
.default({ defaultMode: "notify", notifyHangupDelaySec: 3 });
export type OutboundConfig = z.infer<typeof OutboundConfigSchema>;
@@ -207,6 +219,7 @@ export const VoiceCallStreamingConfigSchema = z
/** WebSocket path for media stream connections */
streamPath: z.string().min(1).default("/voice/stream"),
})
.strict()
.default({
enabled: false,
sttProvider: "openai-realtime",
@@ -223,7 +236,8 @@ export type VoiceCallStreamingConfig = z.infer<
// Main Voice Call Configuration
// -----------------------------------------------------------------------------
export const VoiceCallConfigSchema = z.object({
export const VoiceCallConfigSchema = z
.object({
/** Enable voice call functionality */
enabled: z.boolean().default(false),
@@ -307,7 +321,8 @@ export const VoiceCallConfigSchema = z.object({
/** Timeout for response generation in ms (default 30s) */
responseTimeoutMs: z.number().int().positive().default(30000),
});
})
.strict();
export type VoiceCallConfig = z.infer<typeof VoiceCallConfigSchema>;
+2
View File
@@ -1,4 +1,5 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { whatsappPlugin } from "./src/channel.js";
import { setWhatsAppRuntime } from "./src/runtime.js";
@@ -7,6 +8,7 @@ const plugin = {
id: "whatsapp",
name: "WhatsApp",
description: "WhatsApp channel plugin",
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) {
setWhatsAppRuntime(api.runtime);
api.registerChannel({ plugin: whatsappPlugin });
+2
View File
@@ -1,4 +1,5 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { zaloDock, zaloPlugin } from "./src/channel.js";
import { handleZaloWebhookRequest } from "./src/monitor.js";
@@ -8,6 +9,7 @@ const plugin = {
id: "zalo",
name: "Zalo",
description: "Zalo channel plugin (Bot API)",
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) {
setZaloRuntime(api.runtime);
api.registerChannel({ plugin: zaloPlugin, dock: zaloDock });
+2
View File
@@ -1,4 +1,5 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { zalouserPlugin } from "./src/channel.js";
import { ZalouserToolSchema, executeZalouserTool } from "./src/tool.js";
@@ -8,6 +9,7 @@ const plugin = {
id: "zalouser",
name: "Zalo Personal",
description: "Zalo personal account messaging via zca-cli",
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) {
setZalouserRuntime(api.runtime);
// Register channel plugin (for onboarding & gateway)
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
[ -z "$ROOT" ] && exit 0
node "$ROOT/scripts/format-staged.js"
+4
View File
@@ -55,6 +55,9 @@
"CHANGELOG.md",
"LICENSE",
"scripts/postinstall.js",
"scripts/format-staged.js",
"scripts/setup-git-hooks.js",
"git-hooks/**",
"dist/terminal/**",
"dist/routing/**",
"dist/utils/**",
@@ -161,6 +164,7 @@
"chalk": "^5.6.2",
"chokidar": "^5.0.0",
"chromium-bidi": "12.0.1",
"cli-highlight": "^2.1.11",
"commander": "^14.0.2",
"croner": "^9.1.0",
"detect-libc": "^2.1.2",
+3
View File
@@ -76,6 +76,9 @@ importers:
chromium-bidi:
specifier: 12.0.1
version: 12.0.1(devtools-protocol@0.0.1561482)
cli-highlight:
specifier: ^2.1.11
version: 2.1.11
commander:
specifier: ^14.0.2
version: 14.0.2
+148
View File
@@ -0,0 +1,148 @@
import fs from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const OXFMT_EXTENSIONS = new Set([
".cjs",
".js",
".json",
".jsonc",
".jsx",
".mjs",
".ts",
".tsx",
]);
function getRepoRoot() {
const here = path.dirname(fileURLToPath(import.meta.url));
return path.resolve(here, "..");
}
function runGitCommand(args, options = {}) {
return spawnSync("git", args, {
cwd: options.cwd,
encoding: "utf-8",
stdio: options.stdio ?? "pipe",
});
}
function splitNullDelimited(value) {
if (!value) return [];
const text = String(value);
return text.split("\0").filter(Boolean);
}
function normalizeGitPath(filePath) {
return filePath.replace(/\\/g, "/");
}
function filterOxfmtTargets(paths) {
return paths
.map(normalizeGitPath)
.filter((filePath) =>
(filePath.startsWith("src/") || filePath.startsWith("test/")) &&
OXFMT_EXTENSIONS.has(path.posix.extname(filePath)),
);
}
function findPartiallyStagedFiles(stagedFiles, unstagedFiles) {
const unstaged = new Set(unstagedFiles.map(normalizeGitPath));
return stagedFiles.filter((filePath) => unstaged.has(normalizeGitPath(filePath)));
}
function filterOutPartialTargets(targets, partialTargets) {
if (partialTargets.length === 0) return targets;
const partial = new Set(partialTargets.map(normalizeGitPath));
return targets.filter((filePath) => !partial.has(normalizeGitPath(filePath)));
}
function resolveOxfmtCommand(repoRoot) {
const binName = process.platform === "win32" ? "oxfmt.cmd" : "oxfmt";
const local = path.join(repoRoot, "node_modules", ".bin", binName);
if (fs.existsSync(local)) {
return { command: local, args: [] };
}
const result = spawnSync("oxfmt", ["--version"], { stdio: "ignore" });
if (result.status === 0) {
return { command: "oxfmt", args: [] };
}
return null;
}
function getGitPaths(args, repoRoot) {
const result = runGitCommand(args, { cwd: repoRoot });
if (result.status !== 0) return [];
return splitNullDelimited(result.stdout ?? "");
}
function formatFiles(repoRoot, oxfmt, files) {
const result = spawnSync(oxfmt.command, ["--write", ...oxfmt.args, ...files], {
cwd: repoRoot,
stdio: "inherit",
});
return result.status === 0;
}
function stageFiles(repoRoot, files) {
if (files.length === 0) return true;
const result = runGitCommand(["add", "--", ...files], { cwd: repoRoot, stdio: "inherit" });
return result.status === 0;
}
function main() {
const repoRoot = getRepoRoot();
const staged = getGitPaths([
"diff",
"--cached",
"--name-only",
"-z",
"--diff-filter=ACMR",
], repoRoot);
const targets = filterOxfmtTargets(staged);
if (targets.length === 0) return;
const unstaged = getGitPaths(["diff", "--name-only", "-z"], repoRoot);
const partial = findPartiallyStagedFiles(targets, unstaged);
if (partial.length > 0) {
process.stderr.write("[pre-commit] Skipping partially staged files:\n");
for (const filePath of partial) {
process.stderr.write(`- ${filePath}\n`);
}
process.stderr.write("Stage full files to format them automatically.\n");
}
const filteredTargets = filterOutPartialTargets(targets, partial);
if (filteredTargets.length === 0) return;
const oxfmt = resolveOxfmtCommand(repoRoot);
if (!oxfmt) {
process.stderr.write("[pre-commit] oxfmt not found; skipping format.\n");
return;
}
if (!formatFiles(repoRoot, oxfmt, filteredTargets)) {
process.exitCode = 1;
return;
}
if (!stageFiles(repoRoot, filteredTargets)) {
process.exitCode = 1;
}
}
export {
filterOxfmtTargets,
filterOutPartialTargets,
findPartiallyStagedFiles,
getRepoRoot,
normalizeGitPath,
resolveOxfmtCommand,
splitNullDelimited,
};
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main();
}
+2
View File
@@ -2,6 +2,7 @@ import fs from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { setupGitHooks } from "./setup-git-hooks.js";
function detectPackageManager(ua = process.env.npm_config_user_agent ?? "") {
// Examples:
@@ -252,6 +253,7 @@ function main() {
process.chdir(repoRoot);
ensureExecutable(path.join(repoRoot, "dist", "entry.js"));
setupGitHooks({ repoRoot });
if (!shouldApplyPnpmPatchedDependenciesFallback()) {
return;
+96
View File
@@ -0,0 +1,96 @@
import fs from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const DEFAULT_HOOKS_PATH = "git-hooks";
const PRE_COMMIT_HOOK = "pre-commit";
function getRepoRoot() {
const here = path.dirname(fileURLToPath(import.meta.url));
return path.resolve(here, "..");
}
function runGitCommand(args, options = {}) {
return spawnSync("git", args, {
cwd: options.cwd,
encoding: "utf-8",
stdio: options.stdio ?? "pipe",
});
}
function ensureExecutable(targetPath) {
if (process.platform === "win32") return;
if (!fs.existsSync(targetPath)) return;
try {
const mode = fs.statSync(targetPath).mode & 0o777;
if (mode & 0o100) return;
fs.chmodSync(targetPath, 0o755);
} catch (err) {
console.warn(`[setup-git-hooks] chmod failed: ${err}`);
}
}
function isGitAvailable({ repoRoot = getRepoRoot(), runGit = runGitCommand } = {}) {
const result = runGit(["--version"], { cwd: repoRoot, stdio: "ignore" });
return result.status === 0;
}
function isGitRepo({ repoRoot = getRepoRoot(), runGit = runGitCommand } = {}) {
const result = runGit(["rev-parse", "--is-inside-work-tree"], {
cwd: repoRoot,
stdio: "pipe",
});
if (result.status !== 0) return false;
return String(result.stdout ?? "").trim() === "true";
}
function setHooksPath({
repoRoot = getRepoRoot(),
hooksPath = DEFAULT_HOOKS_PATH,
runGit = runGitCommand,
} = {}) {
const result = runGit(["config", "core.hooksPath", hooksPath], {
cwd: repoRoot,
stdio: "ignore",
});
return result.status === 0;
}
function setupGitHooks({
repoRoot = getRepoRoot(),
hooksPath = DEFAULT_HOOKS_PATH,
runGit = runGitCommand,
} = {}) {
if (!isGitAvailable({ repoRoot, runGit })) {
return { ok: false, reason: "git-missing" };
}
if (!isGitRepo({ repoRoot, runGit })) {
return { ok: false, reason: "not-repo" };
}
if (!setHooksPath({ repoRoot, hooksPath, runGit })) {
return { ok: false, reason: "config-failed" };
}
ensureExecutable(path.join(repoRoot, hooksPath, PRE_COMMIT_HOOK));
return { ok: true };
}
export {
DEFAULT_HOOKS_PATH,
PRE_COMMIT_HOOK,
ensureExecutable,
getRepoRoot,
isGitAvailable,
isGitRepo,
runGitCommand,
setHooksPath,
setupGitHooks,
};
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
setupGitHooks();
}
@@ -22,8 +22,7 @@ import { getApiKeyForModel } from "./model-auth.js";
import { normalizeProviderId, parseModelRef } from "./model-selection.js";
import { ensureClawdbotModelsJson } from "./models-config.js";
const LIVE =
isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.CLAWDBOT_LIVE_TEST);
const LIVE = isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.CLAWDBOT_LIVE_TEST);
const SETUP_TOKEN_RAW = process.env.CLAWDBOT_LIVE_SETUP_TOKEN?.trim() ?? "";
const SETUP_TOKEN_VALUE = process.env.CLAWDBOT_LIVE_SETUP_TOKEN_VALUE?.trim() ?? "";
const SETUP_TOKEN_PROFILE = process.env.CLAWDBOT_LIVE_SETUP_TOKEN_PROFILE?.trim() ?? "";
+50 -1
View File
@@ -1,5 +1,6 @@
import crypto from "node:crypto";
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import path from "node:path";
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox";
@@ -89,6 +90,7 @@ export type ExecToolDefaults = {
security?: ExecSecurity;
ask?: ExecAsk;
node?: string;
pathPrepend?: string[];
agentId?: string;
backgroundMs?: number;
timeoutSec?: number;
@@ -207,6 +209,47 @@ function normalizeNotifyOutput(value: string) {
return value.replace(/\s+/g, " ").trim();
}
function normalizePathPrepend(entries?: string[]) {
if (!Array.isArray(entries)) return [];
const seen = new Set<string>();
const normalized: string[] = [];
for (const entry of entries) {
if (typeof entry !== "string") continue;
const trimmed = entry.trim();
if (!trimmed || seen.has(trimmed)) continue;
seen.add(trimmed);
normalized.push(trimmed);
}
return normalized;
}
function mergePathPrepend(existing: string | undefined, prepend: string[]) {
if (prepend.length === 0) return existing;
const partsExisting = (existing ?? "")
.split(path.delimiter)
.map((part) => part.trim())
.filter(Boolean);
const merged: string[] = [];
const seen = new Set<string>();
for (const part of [...prepend, ...partsExisting]) {
if (seen.has(part)) continue;
seen.add(part);
merged.push(part);
}
return merged.join(path.delimiter);
}
function applyPathPrepend(
env: Record<string, string>,
prepend: string[],
options?: { requireExisting?: boolean },
) {
if (prepend.length === 0) return;
if (options?.requireExisting && !env.PATH) return;
const merged = mergePathPrepend(env.PATH, prepend);
if (merged) env.PATH = merged;
}
function maybeNotifyOnExit(session: ProcessSession, status: "completed" | "failed") {
if (!session.backgrounded || !session.notifyOnExit || session.exitNotified) return;
const sessionKey = session.sessionKey?.trim();
@@ -240,6 +283,7 @@ export function createExecTool(
typeof defaults?.timeoutSec === "number" && defaults.timeoutSec > 0
? defaults.timeoutSec
: 1800;
const defaultPathPrepend = normalizePathPrepend(defaults?.pathPrepend);
const notifyOnExit = defaults?.notifyOnExit !== false;
const notifySessionKey = defaults?.sessionKey?.trim() || undefined;
@@ -379,6 +423,7 @@ export function createExecTool(
containerWorkdir: containerWorkdir ?? sandbox.containerWorkdir,
})
: mergedEnv;
applyPathPrepend(env, defaultPathPrepend);
if (host === "node") {
if (security === "deny") {
@@ -417,6 +462,10 @@ export function createExecTool(
);
}
const argv = buildNodeShellCommand(params.command, nodeInfo?.platform);
const nodeEnv = params.env ? { ...params.env } : undefined;
if (nodeEnv) {
applyPathPrepend(nodeEnv, defaultPathPrepend, { requireExisting: true });
}
const invokeParams: Record<string, unknown> = {
nodeId,
command: "system.run",
@@ -424,7 +473,7 @@ export function createExecTool(
command: argv,
rawCommand: params.command,
cwd: workdir,
env: params.env,
env: nodeEnv,
timeoutMs: typeof params.timeout === "number" ? params.timeout * 1000 : undefined,
agentId: defaults?.agentId,
sessionKey: defaults?.sessionKey,
+30
View File
@@ -1,3 +1,5 @@
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { peekSystemEvents, resetSystemEventsForTest } from "../infra/system-events.js";
import { getFinishedSession, resetProcessRegistryForTests } from "./bash-process-registry.js";
@@ -275,6 +277,34 @@ describe("exec notifyOnExit", () => {
});
});
describe("exec PATH handling", () => {
const originalPath = process.env.PATH;
const originalShell = process.env.SHELL;
beforeEach(() => {
if (!isWin) process.env.SHELL = "/bin/bash";
});
afterEach(() => {
process.env.PATH = originalPath;
if (!isWin) process.env.SHELL = originalShell;
});
it("prepends configured path entries", async () => {
const basePath = isWin ? "C:\\Windows\\System32" : "/usr/bin";
const prepend = isWin ? ["C:\\custom\\bin", "C:\\oss\\bin"] : ["/custom/bin", "/opt/oss/bin"];
process.env.PATH = basePath;
const tool = createExecTool({ pathPrepend: prepend });
const result = await tool.execute("call1", {
command: isWin ? "Write-Output $env:PATH" : "echo $PATH",
});
const text = normalizeText(result.content.find((c) => c.type === "text")?.text);
expect(text).toBe([...prepend, basePath].join(path.delimiter));
});
});
describe("buildDockerExecArgs", () => {
it("prepends custom PATH after login shell sourcing to preserve both custom and system tools", () => {
const args = buildDockerExecArgs({
+16 -4
View File
@@ -22,6 +22,16 @@ vi.mock("../config/config.js", async (importOriginal) => {
import { createClawdbotTools } from "./clawdbot-tools.js";
const waitForCalls = async (getCount: () => number, count: number, timeoutMs = 2000) => {
const start = Date.now();
while (getCount() < count) {
if (Date.now() - start > timeoutMs) {
throw new Error(`timed out waiting for ${count} calls`);
}
await new Promise((resolve) => setTimeout(resolve, 0));
}
};
describe("sessions tools", () => {
it("uses number (not integer) in tool schemas for Gemini compatibility", () => {
const tools = createClawdbotTools();
@@ -240,8 +250,9 @@ describe("sessions tools", () => {
runId: "run-1",
delivery: { status: "pending", mode: "announce" },
});
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
await waitForCalls(() => calls.filter((call) => call.method === "agent").length, 4);
await waitForCalls(() => calls.filter((call) => call.method === "agent.wait").length, 4);
await waitForCalls(() => calls.filter((call) => call.method === "chat.history").length, 4);
const waitPromise = tool.execute("call6", {
sessionKey: "main",
@@ -255,8 +266,9 @@ describe("sessions tools", () => {
delivery: { status: "pending", mode: "announce" },
});
expect(typeof (waited.details as { runId?: string }).runId).toBe("string");
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
await waitForCalls(() => calls.filter((call) => call.method === "agent").length, 8);
await waitForCalls(() => calls.filter((call) => call.method === "agent.wait").length, 8);
await waitForCalls(() => calls.filter((call) => call.method === "chat.history").length, 8);
const agentCalls = calls.filter((call) => call.method === "agent");
const waitCalls = calls.filter((call) => call.method === "agent.wait");
@@ -157,4 +157,79 @@ describe("clawdbot-tools: subagents", () => {
// Session should be deleted since cleanup=delete
expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true);
});
it("sessions_spawn announces with requester accountId", async () => {
resetSubagentRegistryForTests();
callGatewayMock.mockReset();
const calls: Array<{ method?: string; params?: unknown }> = [];
let agentCallCount = 0;
let childRunId: string | undefined;
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown };
calls.push(request);
if (request.method === "agent") {
agentCallCount += 1;
const runId = `run-${agentCallCount}`;
const params = request.params as { lane?: string; sessionKey?: string } | undefined;
if (params?.lane === "subagent") {
childRunId = runId;
}
return {
runId,
status: "accepted",
acceptedAt: 4000 + agentCallCount,
};
}
if (request.method === "agent.wait") {
const params = request.params as { runId?: string; timeoutMs?: number } | undefined;
return { runId: params?.runId ?? "run-1", status: "ok", startedAt: 1000, endedAt: 2000 };
}
if (request.method === "sessions.delete" || request.method === "sessions.patch") {
return { ok: true };
}
return {};
});
const tool = createClawdbotTools({
agentSessionKey: "main",
agentChannel: "whatsapp",
agentAccountId: "kev",
}).find((candidate) => candidate.name === "sessions_spawn");
if (!tool) throw new Error("missing sessions_spawn tool");
const result = await tool.execute("call2", {
task: "do thing",
runTimeoutSeconds: 1,
cleanup: "keep",
});
expect(result.details).toMatchObject({
status: "accepted",
runId: "run-1",
});
if (!childRunId) throw new Error("missing child runId");
emitAgentEvent({
runId: childRunId,
stream: "lifecycle",
data: {
phase: "end",
startedAt: 1000,
endedAt: 2000,
},
});
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
const agentCalls = calls.filter((call) => call.method === "agent");
expect(agentCalls).toHaveLength(2);
const announceParams = agentCalls[1]?.params as
| { accountId?: string; channel?: string; deliver?: boolean }
| undefined;
expect(announceParams?.deliver).toBe(true);
expect(announceParams?.channel).toBe("whatsapp");
expect(announceParams?.accountId).toBe("kev");
});
});
+1 -1
View File
@@ -4,7 +4,7 @@ import type { ThinkLevel } from "../auto-reply/thinking.js";
import type { ClawdbotConfig } from "../config/config.js";
import { isTruthyEnvValue } from "../infra/env.js";
import { shouldLogVerbose } from "../globals.js";
import { createSubsystemLogger } from "../logging.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { runCommandWithTimeout } from "../process/exec.js";
import { resolveUserPath } from "../utils.js";
import { resolveClawdbotDocsPath } from "./docs-path.js";
+1 -2
View File
@@ -3,8 +3,7 @@ import { describe, expect, it } from "vitest";
import { isTruthyEnvValue } from "../infra/env.js";
const GEMINI_KEY = process.env.GEMINI_API_KEY ?? "";
const LIVE =
isTruthyEnvValue(process.env.GEMINI_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE);
const LIVE = isTruthyEnvValue(process.env.GEMINI_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE);
const describeLive = LIVE && GEMINI_KEY ? describe : describe.skip;
+1 -2
View File
@@ -5,8 +5,7 @@ import { isTruthyEnvValue } from "../infra/env.js";
const MINIMAX_KEY = process.env.MINIMAX_API_KEY ?? "";
const MINIMAX_BASE_URL = process.env.MINIMAX_BASE_URL?.trim() || "https://api.minimax.io/anthropic";
const MINIMAX_MODEL = process.env.MINIMAX_MODEL?.trim() || "MiniMax-M2.1";
const LIVE =
isTruthyEnvValue(process.env.MINIMAX_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE);
const LIVE = isTruthyEnvValue(process.env.MINIMAX_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE);
const describeLive = LIVE && MINIMAX_KEY ? describe : describe.skip;
+1 -2
View File
@@ -15,8 +15,7 @@ import { getApiKeyForModel } from "./model-auth.js";
import { ensureClawdbotModelsJson } from "./models-config.js";
import { isRateLimitErrorMessage } from "./pi-embedded-helpers/errors.js";
const LIVE =
isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.CLAWDBOT_LIVE_TEST);
const LIVE = isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.CLAWDBOT_LIVE_TEST);
const DIRECT_ENABLED = Boolean(process.env.CLAWDBOT_LIVE_MODELS?.trim());
const REQUIRE_PROFILE_KEYS = isTruthyEnvValue(process.env.CLAWDBOT_LIVE_REQUIRE_PROFILE_KEYS);
@@ -6,8 +6,7 @@ import type { ClawdbotConfig } from "../config/config.js";
import { applyExtraParamsToAgent } from "./pi-embedded-runner.js";
const OPENAI_KEY = process.env.OPENAI_API_KEY ?? "";
const LIVE =
isTruthyEnvValue(process.env.OPENAI_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE);
const LIVE = isTruthyEnvValue(process.env.OPENAI_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE);
const describeLive = LIVE && OPENAI_KEY ? describe : describe.skip;
+1 -1
View File
@@ -1,3 +1,3 @@
import { createSubsystemLogger } from "../../logging.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
export const log = createSubsystemLogger("agent/embedded");
+1 -1
View File
@@ -1,6 +1,6 @@
import { parseReplyDirectives } from "../auto-reply/reply/reply-directives.js";
import { formatToolAggregate } from "../auto-reply/tool-meta.js";
import { createSubsystemLogger } from "../logging.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import type { InlineCodeState } from "../markdown/code-spans.js";
import { buildCodeSpanIndex, createInlineCodeState } from "../markdown/code-spans.js";
import { EmbeddedBlockChunker } from "./pi-embedded-block-chunker.js";
@@ -0,0 +1,98 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import { describe, expect, it } from "vitest";
import { __testing } from "./compaction-safeguard.js";
const { collectToolFailures, formatToolFailuresSection } = __testing;
describe("compaction-safeguard tool failures", () => {
it("formats tool failures with meta and summary", () => {
const messages: AgentMessage[] = [
{
role: "toolResult",
toolCallId: "call-1",
toolName: "exec",
isError: true,
details: { status: "failed", exitCode: 1 },
content: [{ type: "text", text: "ENOENT: missing file" }],
timestamp: Date.now(),
},
{
role: "toolResult",
toolCallId: "call-2",
toolName: "read",
isError: false,
content: [{ type: "text", text: "ok" }],
timestamp: Date.now(),
},
];
const failures = collectToolFailures(messages);
expect(failures).toHaveLength(1);
const section = formatToolFailuresSection(failures);
expect(section).toContain("## Tool Failures");
expect(section).toContain("exec (status=failed exitCode=1): ENOENT: missing file");
});
it("dedupes by toolCallId and handles empty output", () => {
const messages: AgentMessage[] = [
{
role: "toolResult",
toolCallId: "call-1",
toolName: "exec",
isError: true,
details: { exitCode: 2 },
content: [],
timestamp: Date.now(),
},
{
role: "toolResult",
toolCallId: "call-1",
toolName: "exec",
isError: true,
content: [{ type: "text", text: "ignored" }],
timestamp: Date.now(),
},
];
const failures = collectToolFailures(messages);
expect(failures).toHaveLength(1);
const section = formatToolFailuresSection(failures);
expect(section).toContain("exec (exitCode=2): failed");
});
it("caps the number of failures and adds overflow line", () => {
const messages: AgentMessage[] = Array.from({ length: 9 }, (_, idx) => ({
role: "toolResult",
toolCallId: `call-${idx}`,
toolName: "exec",
isError: true,
content: [{ type: "text", text: `error ${idx}` }],
timestamp: Date.now(),
}));
const failures = collectToolFailures(messages);
const section = formatToolFailuresSection(failures);
expect(section).toContain("## Tool Failures");
expect(section).toContain("...and 1 more");
});
it("omits section when there are no tool failures", () => {
const messages: AgentMessage[] = [
{
role: "toolResult",
toolCallId: "ok",
toolName: "exec",
isError: false,
content: [{ type: "text", text: "ok" }],
timestamp: Date.now(),
},
];
const failures = collectToolFailures(messages);
const section = formatToolFailuresSection(failures);
expect(section).toBe("");
});
});
@@ -10,6 +10,101 @@ const FALLBACK_SUMMARY =
const TURN_PREFIX_INSTRUCTIONS =
"This summary covers the prefix of a split turn. Focus on the original request," +
" early progress, and any details needed to understand the retained suffix.";
const MAX_TOOL_FAILURES = 8;
const MAX_TOOL_FAILURE_CHARS = 240;
type ToolFailure = {
toolCallId: string;
toolName: string;
summary: string;
meta?: string;
};
function normalizeFailureText(text: string): string {
return text.replace(/\s+/g, " ").trim();
}
function truncateFailureText(text: string, maxChars: number): string {
if (text.length <= maxChars) return text;
return `${text.slice(0, Math.max(0, maxChars - 3))}...`;
}
function formatToolFailureMeta(details: unknown): string | undefined {
if (!details || typeof details !== "object") return undefined;
const record = details as Record<string, unknown>;
const status = typeof record.status === "string" ? record.status : undefined;
const exitCode =
typeof record.exitCode === "number" && Number.isFinite(record.exitCode)
? record.exitCode
: undefined;
const parts: string[] = [];
if (status) parts.push(`status=${status}`);
if (exitCode !== undefined) parts.push(`exitCode=${exitCode}`);
return parts.length > 0 ? parts.join(" ") : undefined;
}
function extractToolResultText(content: unknown): string {
if (!Array.isArray(content)) return "";
const parts: string[] = [];
for (const block of content) {
if (!block || typeof block !== "object") continue;
const rec = block as { type?: unknown; text?: unknown };
if (rec.type === "text" && typeof rec.text === "string") {
parts.push(rec.text);
}
}
return parts.join("\n");
}
function collectToolFailures(messages: AgentMessage[]): ToolFailure[] {
const failures: ToolFailure[] = [];
const seen = new Set<string>();
for (const message of messages) {
if (!message || typeof message !== "object") continue;
const role = (message as { role?: unknown }).role;
if (role !== "toolResult") continue;
const toolResult = message as {
toolCallId?: unknown;
toolName?: unknown;
content?: unknown;
details?: unknown;
isError?: unknown;
};
if (toolResult.isError !== true) continue;
const toolCallId =
typeof toolResult.toolCallId === "string" ? toolResult.toolCallId : "";
if (!toolCallId || seen.has(toolCallId)) continue;
seen.add(toolCallId);
const toolName =
typeof toolResult.toolName === "string" && toolResult.toolName.trim()
? toolResult.toolName
: "tool";
const rawText = extractToolResultText(toolResult.content);
const meta = formatToolFailureMeta(toolResult.details);
const normalized = normalizeFailureText(rawText);
const summary = truncateFailureText(
normalized || (meta ? "failed" : "failed (no output)"),
MAX_TOOL_FAILURE_CHARS,
);
failures.push({ toolCallId, toolName, summary, meta });
}
return failures;
}
function formatToolFailuresSection(failures: ToolFailure[]): string {
if (failures.length === 0) return "";
const lines = failures.slice(0, MAX_TOOL_FAILURES).map((failure) => {
const meta = failure.meta ? ` (${failure.meta})` : "";
return `- ${failure.toolName}${meta}: ${failure.summary}`;
});
if (failures.length > MAX_TOOL_FAILURES) {
lines.push(`- ...and ${failures.length - MAX_TOOL_FAILURES} more`);
}
return `\n\n## Tool Failures\n${lines.join("\n")}`;
}
function computeFileLists(fileOps: FileOperations): {
readFiles: string[];
@@ -103,7 +198,12 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
const { preparation, customInstructions, signal } = event;
const { readFiles, modifiedFiles } = computeFileLists(preparation.fileOps);
const fileOpsSummary = formatFileOperations(readFiles, modifiedFiles);
const fallbackSummary = `${FALLBACK_SUMMARY}${fileOpsSummary}`;
const toolFailures = collectToolFailures([
...preparation.messagesToSummarize,
...preparation.turnPrefixMessages,
]);
const toolFailureSection = formatToolFailuresSection(toolFailures);
const fallbackSummary = `${FALLBACK_SUMMARY}${toolFailureSection}${fileOpsSummary}`;
const model = ctx.model;
if (!model) {
@@ -162,6 +262,7 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
summary = `${historySummary}\n\n---\n\n**Turn Context (split turn):**\n\n${prefixSummary}`;
}
summary += toolFailureSection;
summary += fileOpsSummary;
return {
@@ -189,3 +290,8 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
}
});
}
export const __testing = {
collectToolFailures,
formatToolFailuresSection,
} as const;
+2
View File
@@ -81,6 +81,7 @@ function resolveExecConfig(cfg: ClawdbotConfig | undefined) {
security: globalExec?.security,
ask: globalExec?.ask,
node: globalExec?.node,
pathPrepend: globalExec?.pathPrepend,
backgroundMs: globalExec?.backgroundMs,
timeoutSec: globalExec?.timeoutSec,
cleanupMs: globalExec?.cleanupMs,
@@ -207,6 +208,7 @@ export function createClawdbotCodingTools(options?: {
security: options?.exec?.security ?? execConfig.security,
ask: options?.exec?.ask ?? execConfig.ask,
node: options?.exec?.node ?? execConfig.node,
pathPrepend: options?.exec?.pathPrepend ?? execConfig.pathPrepend,
agentId,
cwd: options?.workspaceDir,
allowBackground,
+41
View File
@@ -0,0 +1,41 @@
import crypto from "node:crypto";
import type { SandboxDockerConfig, SandboxWorkspaceAccess } from "./types.js";
type SandboxHashInput = {
docker: SandboxDockerConfig;
workspaceAccess: SandboxWorkspaceAccess;
workspaceDir: string;
agentWorkspaceDir: string;
};
function isPrimitive(value: unknown): value is string | number | boolean | bigint | symbol | null {
return value === null || (typeof value !== "object" && typeof value !== "function");
}
function normalizeForHash(value: unknown): unknown {
if (value === undefined) return undefined;
if (Array.isArray(value)) {
const normalized = value.map(normalizeForHash).filter((item): item is unknown => item !== undefined);
const primitives = normalized.filter(isPrimitive);
if (primitives.length === normalized.length) {
return [...primitives].sort((a, b) => String(a).localeCompare(String(b)));
}
return normalized;
}
if (value && typeof value === "object") {
const entries = Object.entries(value).sort(([a], [b]) => a.localeCompare(b));
const normalized: Record<string, unknown> = {};
for (const [key, entryValue] of entries) {
const next = normalizeForHash(entryValue);
if (next !== undefined) normalized[key] = next;
}
return normalized;
}
return value;
}
export function computeSandboxConfigHash(input: SandboxHashInput): string {
const payload = normalizeForHash(input);
const raw = JSON.stringify(payload);
return crypto.createHash("sha1").update(raw).digest("hex");
}
+81 -5
View File
@@ -1,10 +1,14 @@
import { spawn } from "node:child_process";
import { defaultRuntime } from "../../runtime.js";
import { DEFAULT_SANDBOX_IMAGE, SANDBOX_AGENT_WORKSPACE_MOUNT } from "./constants.js";
import { updateRegistry } from "./registry.js";
import { resolveSandboxScopeKey, slugifySessionKey } from "./shared.js";
import { readRegistry, updateRegistry } from "./registry.js";
import { computeSandboxConfigHash } from "./config-hash.js";
import { resolveSandboxAgentId, resolveSandboxScopeKey, slugifySessionKey } from "./shared.js";
import type { SandboxConfig, SandboxDockerConfig, SandboxWorkspaceAccess } from "./types.js";
const HOT_CONTAINER_WINDOW_MS = 5 * 60 * 1000;
export function execDocker(args: string[], opts?: { allowFailure?: boolean }) {
return new Promise<{ stdout: string; stderr: string; code: number }>((resolve, reject) => {
const child = spawn("docker", args, {
@@ -99,12 +103,16 @@ export function buildSandboxCreateArgs(params: {
scopeKey: string;
createdAtMs?: number;
labels?: Record<string, string>;
configHash?: string;
}) {
const createdAtMs = params.createdAtMs ?? Date.now();
const args = ["create", "--name", params.name];
args.push("--label", "clawdbot.sandbox=1");
args.push("--label", `clawdbot.sessionKey=${params.scopeKey}`);
args.push("--label", `clawdbot.createdAtMs=${createdAtMs}`);
if (params.configHash) {
args.push("--label", `clawdbot.configHash=${params.configHash}`);
}
for (const [key, value] of Object.entries(params.labels ?? {})) {
if (key && value) args.push("--label", `${key}=${value}`);
}
@@ -161,6 +169,7 @@ async function createSandboxContainer(params: {
workspaceAccess: SandboxWorkspaceAccess;
agentWorkspaceDir: string;
scopeKey: string;
configHash?: string;
}) {
const { name, cfg, workspaceDir, scopeKey } = params;
await ensureDockerImage(cfg.image);
@@ -169,6 +178,7 @@ async function createSandboxContainer(params: {
name,
cfg,
scopeKey,
configHash: params.configHash,
});
args.push("--workdir", cfg.workdir);
const mainMountSuffix =
@@ -191,6 +201,28 @@ async function createSandboxContainer(params: {
}
}
async function readContainerConfigHash(containerName: string): Promise<string | null> {
const result = await execDocker(
["inspect", "-f", '{{ index .Config.Labels "clawdbot.configHash" }}', containerName],
{ allowFailure: true },
);
if (result.code !== 0) return null;
const raw = result.stdout.trim();
if (!raw || raw === "<no value>") return null;
return raw;
}
function formatSandboxRecreateHint(params: { scope: SandboxConfig["scope"]; sessionKey: string }) {
if (params.scope === "session") {
return `clawdbot sandbox recreate --session ${params.sessionKey}`;
}
if (params.scope === "agent") {
const agentId = resolveSandboxAgentId(params.sessionKey) ?? "main";
return `clawdbot sandbox recreate --agent ${agentId}`;
}
return "clawdbot sandbox recreate --all";
}
export async function ensureSandboxContainer(params: {
sessionKey: string;
workspaceDir: string;
@@ -201,8 +233,51 @@ export async function ensureSandboxContainer(params: {
const slug = params.cfg.scope === "shared" ? "shared" : slugifySessionKey(scopeKey);
const name = `${params.cfg.docker.containerPrefix}${slug}`;
const containerName = name.slice(0, 63);
const expectedHash = computeSandboxConfigHash({
docker: params.cfg.docker,
workspaceAccess: params.cfg.workspaceAccess,
workspaceDir: params.workspaceDir,
agentWorkspaceDir: params.agentWorkspaceDir,
});
const now = Date.now();
const state = await dockerContainerState(containerName);
if (!state.exists) {
let hasContainer = state.exists;
let running = state.running;
let currentHash: string | null = null;
let hashMismatch = false;
let registryEntry:
| {
lastUsedAtMs: number;
configHash?: string;
}
| undefined;
if (hasContainer) {
const registry = await readRegistry();
registryEntry = registry.entries.find((entry) => entry.containerName === containerName);
currentHash = await readContainerConfigHash(containerName);
if (!currentHash) {
currentHash =
registryEntry?.configHash ?? null;
}
hashMismatch = !currentHash || currentHash !== expectedHash;
if (hashMismatch) {
const lastUsedAtMs = registryEntry?.lastUsedAtMs;
const isHot =
running &&
(typeof lastUsedAtMs !== "number" || now - lastUsedAtMs < HOT_CONTAINER_WINDOW_MS);
if (isHot) {
const hint = formatSandboxRecreateHint({ scope: params.cfg.scope, sessionKey: scopeKey });
defaultRuntime.log(
`Sandbox config changed for ${containerName} (recently used). Recreate to apply: ${hint}`,
);
} else {
await execDocker(["rm", "-f", containerName], { allowFailure: true });
hasContainer = false;
running = false;
}
}
}
if (!hasContainer) {
await createSandboxContainer({
name: containerName,
cfg: params.cfg.docker,
@@ -210,17 +285,18 @@ export async function ensureSandboxContainer(params: {
workspaceAccess: params.cfg.workspaceAccess,
agentWorkspaceDir: params.agentWorkspaceDir,
scopeKey,
configHash: expectedHash,
});
} else if (!state.running) {
} else if (!running) {
await execDocker(["start", containerName]);
}
const now = Date.now();
await updateRegistry({
containerName,
sessionKey: scopeKey,
createdAtMs: now,
lastUsedAtMs: now,
image: params.cfg.docker.image,
configHash: hashMismatch && running ? currentHash ?? undefined : expectedHash,
});
return containerName;
}
+2
View File
@@ -12,6 +12,7 @@ export type SandboxRegistryEntry = {
createdAtMs: number;
lastUsedAtMs: number;
image: string;
configHash?: string;
};
type SandboxRegistry = {
@@ -56,6 +57,7 @@ export async function updateRegistry(entry: SandboxRegistryEntry) {
...entry,
createdAtMs: existing?.createdAtMs ?? entry.createdAtMs,
image: existing?.image ?? entry.image,
configHash: entry.configHash ?? existing?.configHash,
});
await writeRegistry({ entries: next });
}
+18 -1
View File
@@ -1,5 +1,22 @@
import fs from "node:fs";
import path from "node:path";
import { spawn } from "node:child_process";
function resolvePowerShellPath(): string {
const systemRoot = process.env.SystemRoot || process.env.WINDIR;
if (systemRoot) {
const candidate = path.join(
systemRoot,
"System32",
"WindowsPowerShell",
"v1.0",
"powershell.exe",
);
if (fs.existsSync(candidate)) return candidate;
}
return "powershell.exe";
}
export function getShellConfig(): { shell: string; args: string[] } {
if (process.platform === "win32") {
// Use PowerShell instead of cmd.exe on Windows.
@@ -8,7 +25,7 @@ export function getShellConfig(): { shell: string; args: string[] } {
// When Node.js spawns cmd.exe with piped stdio, these utilities produce no output.
// PowerShell properly captures and redirects their output to stdout.
return {
shell: "powershell.exe",
shell: resolvePowerShellPath(),
args: ["-NoProfile", "-NonInteractive", "-Command"],
};
}
+1 -1
View File
@@ -3,7 +3,7 @@ import path from "node:path";
import chokidar, { type FSWatcher } from "chokidar";
import type { ClawdbotConfig } from "../../config/config.js";
import { createSubsystemLogger } from "../../logging.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { CONFIG_DIR, resolveUserPath } from "../../utils.js";
type SkillsChangeEvent = {
+1 -1
View File
@@ -8,7 +8,7 @@ import {
} from "@mariozechner/pi-coding-agent";
import type { ClawdbotConfig } from "../../config/config.js";
import { createSubsystemLogger } from "../../logging.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { CONFIG_DIR, resolveUserPath } from "../../utils.js";
import { resolveBundledSkillsDir } from "./bundled-dir.js";
import { shouldIncludeSkill } from "./config.js";
+1
View File
@@ -358,6 +358,7 @@ export function buildAgentSystemPrompt(params: {
"Default: do not narrate routine, low-risk tool calls (just call the tool).",
"Narrate only when it helps: multi-step work, complex/challenging problems, sensitive actions (e.g., deletions), or when the user explicitly asks.",
"Keep narration brief and value-dense; avoid repeating obvious steps.",
"Use plain human language for narration unless in a technical context.",
"",
"## Clawdbot CLI Quick Reference",
"Clawdbot is controlled via subcommands. Do not invent commands.",
+1 -1
View File
@@ -1,7 +1,7 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { ImageContent } from "@mariozechner/pi-ai";
import { createSubsystemLogger } from "../logging.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { getImageMetadata, resizeToJpeg } from "../media/image-ops.js";
type ToolContentBlock = AgentToolResult<unknown>["content"][number];
+1 -1
View File
@@ -2,7 +2,7 @@ import crypto from "node:crypto";
import { callGateway } from "../../gateway/call.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { createSubsystemLogger } from "../../logging.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import type { GatewayMessageChannel } from "../../utils/message-channel.js";
import { AGENT_LANE_NESTED } from "../lanes.js";
import { readLatestAssistantReply, runAgentStep } from "./agent-step.js";
+1 -1
View File
@@ -25,7 +25,7 @@ function normalizeWhitespace(value: string): string {
.trim();
}
function htmlToMarkdown(html: string): { text: string; title?: string } {
export function htmlToMarkdown(html: string): { text: string; title?: string } {
const titleMatch = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
const title = titleMatch ? normalizeWhitespace(stripTags(titleMatch[1])) : undefined;
let text = html
+32 -1
View File
@@ -18,6 +18,7 @@ import {
} from "./web-shared.js";
import {
extractReadableContent,
htmlToMarkdown,
markdownToText,
truncateText,
type ExtractMode,
@@ -28,6 +29,7 @@ export { extractReadableContent } from "./web-fetch-utils.js";
const EXTRACT_MODES = ["markdown", "text"] as const;
const DEFAULT_FETCH_MAX_CHARS = 50_000;
const DEFAULT_ERROR_MAX_CHARS = 4_000;
const DEFAULT_FIRECRAWL_BASE_URL = "https://api.firecrawl.dev";
const DEFAULT_FIRECRAWL_MAX_AGE_MS = 172_800_000;
const DEFAULT_FETCH_USER_AGENT =
@@ -142,6 +144,30 @@ function resolveMaxChars(value: unknown, fallback: number): number {
return Math.max(100, Math.floor(parsed));
}
function looksLikeHtml(value: string): boolean {
const trimmed = value.trimStart();
if (!trimmed) return false;
const head = trimmed.slice(0, 256).toLowerCase();
return head.startsWith("<!doctype html") || head.startsWith("<html");
}
function formatWebFetchErrorDetail(params: {
detail: string;
contentType?: string | null;
maxChars: number;
}): string {
const { detail, contentType, maxChars } = params;
if (!detail) return "";
let text = detail;
const contentTypeLower = contentType?.toLowerCase();
if (contentTypeLower?.includes("text/html") || looksLikeHtml(detail)) {
const rendered = htmlToMarkdown(detail);
const withTitle = rendered.title ? `${rendered.title}\n${rendered.text}` : rendered.text;
text = markdownToText(withTitle);
}
const truncated = truncateText(text.trim(), maxChars);
return truncated.text;
}
export async function fetchFirecrawlContent(params: {
url: string;
extractMode: ExtractMode;
@@ -329,7 +355,12 @@ async function runWebFetch(params: {
writeCache(FETCH_CACHE, cacheKey, payload, params.cacheTtlMs);
return payload;
}
const detail = await readResponseText(res);
const rawDetail = await readResponseText(res);
const detail = formatWebFetchErrorDetail({
detail: rawDetail,
contentType: res.headers.get("content-type"),
maxChars: DEFAULT_ERROR_MAX_CHARS,
});
throw new Error(`Web fetch failed (${res.status}): ${detail || res.statusText}`);
}
+74
View File
@@ -49,6 +49,20 @@ function firecrawlError(): MockResponse {
};
}
function errorHtmlResponse(
html: string,
status = 404,
url = "https://example.com/",
contentType: string | null = "text/html; charset=utf-8",
): MockResponse {
return {
ok: false,
status,
url,
headers: contentType ? makeHeaders({ "content-type": contentType }) : makeHeaders({}),
text: async () => html,
};
}
function requestUrl(input: RequestInfo): string {
if (typeof input === "string") return input;
if (input instanceof URL) return input.toString();
@@ -182,4 +196,64 @@ describe("web_fetch extraction fallbacks", () => {
expect(details.extractor).toBe("firecrawl");
expect(details.text).toContain("firecrawl fallback");
});
it("strips and truncates HTML from error responses", async () => {
const long = "x".repeat(12_000);
const html =
"<!doctype html><html><head><title>Not Found</title></head><body><h1>Not Found</h1><p>" +
long +
"</p></body></html>";
const mockFetch = vi.fn((input: RequestInfo) =>
Promise.resolve(errorHtmlResponse(html, 404, requestUrl(input), "Text/HTML; charset=utf-8")),
);
// @ts-expect-error mock fetch
global.fetch = mockFetch;
const tool = createWebFetchTool({
config: {
tools: {
web: {
fetch: { cacheTtlMinutes: 0, firecrawl: { enabled: false } },
},
},
},
sandboxed: false,
});
let message = "";
try {
await tool?.execute?.("call", { url: "https://example.com/missing" });
} catch (error) {
message = (error as Error).message;
}
expect(message).toContain("Web fetch failed (404):");
expect(message).toContain("Not Found");
expect(message).not.toContain("<html");
expect(message.length).toBeLessThan(5_000);
});
it("strips HTML errors when content-type is missing", async () => {
const html =
"<!DOCTYPE HTML><html><head><title>Oops</title></head><body><h1>Oops</h1></body></html>";
const mockFetch = vi.fn((input: RequestInfo) =>
Promise.resolve(errorHtmlResponse(html, 500, requestUrl(input), null)),
);
// @ts-expect-error mock fetch
global.fetch = mockFetch;
const tool = createWebFetchTool({
config: {
tools: {
web: {
fetch: { cacheTtlMinutes: 0, firecrawl: { enabled: false } },
},
},
},
sandboxed: false,
});
await expect(tool?.execute?.("call", { url: "https://example.com/oops" })).rejects.toThrow(
/Web fetch failed \(500\):.*Oops/,
);
});
});
@@ -59,14 +59,14 @@ describe("commands registry args", () => {
name: "mode",
description: "mode",
type: "string",
choices: ["off", "tokens", "full"],
choices: ["off", "tokens", "full", "cost"],
},
],
};
const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
expect(menu?.arg.name).toBe("mode");
expect(menu?.choices).toEqual(["off", "tokens", "full"]);
expect(menu?.choices).toEqual(["off", "tokens", "full", "cost"]);
});
it("does not show menus when arg already provided", () => {
@@ -82,7 +82,7 @@ describe("commands registry args", () => {
name: "mode",
description: "mode",
type: "string",
choices: ["off", "tokens", "full"],
choices: ["off", "tokens", "full", "cost"],
},
],
};
@@ -141,7 +141,7 @@ describe("commands registry args", () => {
name: "mode",
description: "on or off",
type: "string",
choices: ["off", "tokens", "full"],
choices: ["off", "tokens", "full", "cost"],
},
],
};
+3 -3
View File
@@ -233,14 +233,14 @@ function buildChatCommands(): ChatCommandDefinition[] {
defineChatCommand({
key: "usage",
nativeName: "usage",
description: "Toggle per-response usage line.",
description: "Usage footer or cost summary.",
textAlias: "/usage",
args: [
{
name: "mode",
description: "off, tokens, or full",
description: "off, tokens, full, or cost",
type: "string",
choices: ["off", "tokens", "full"],
choices: ["off", "tokens", "full", "cost"],
},
],
argsMenu: "auto",
+32 -90
View File
@@ -18,10 +18,7 @@ import {
updateSessionStore,
} from "../../config/sessions.js";
import { logVerbose } from "../../globals.js";
import {
emitAgentEvent,
registerAgentRunContext,
} from "../../infra/agent-events.js";
import { emitAgentEvent, registerAgentRunContext } from "../../infra/agent-events.js";
import { defaultRuntime } from "../../runtime.js";
import {
isMarkdownCapableMessageChannel,
@@ -32,20 +29,11 @@ import type { TemplateContext } from "../templating.js";
import type { VerboseLevel } from "../thinking.js";
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js";
import {
buildThreadingToolContext,
resolveEnforceFinalTag,
} from "./agent-runner-utils.js";
import {
createBlockReplyPayloadKey,
type BlockReplyPipeline,
} from "./block-reply-pipeline.js";
import { buildThreadingToolContext, resolveEnforceFinalTag } from "./agent-runner-utils.js";
import { createBlockReplyPayloadKey, type BlockReplyPipeline } from "./block-reply-pipeline.js";
import type { FollowupRun } from "./queue.js";
import { parseReplyDirectives } from "./reply-directives.js";
import {
applyReplyTagsToPayload,
isRenderablePayload,
} from "./reply-payloads.js";
import { applyReplyTagsToPayload, isRenderablePayload } from "./reply-payloads.js";
import type { TypingSignaler } from "./typing-mode.js";
export type AgentRunLoopResult =
@@ -108,12 +96,9 @@ export async function runAgentTurnWithFallback(params: {
while (true) {
try {
const allowPartialStream = !(
params.followupRun.run.reasoningLevel === "stream" &&
params.opts?.onReasoningStream
params.followupRun.run.reasoningLevel === "stream" && params.opts?.onReasoningStream
);
const normalizeStreamingText = (
payload: ReplyPayload,
): { text?: string; skip: boolean } => {
const normalizeStreamingText = (payload: ReplyPayload): { text?: string; skip: boolean } => {
if (!allowPartialStream) return { skip: true };
let text = payload.text;
if (!params.isHeartbeat && text?.includes("HEARTBEAT_OK")) {
@@ -137,9 +122,7 @@ export async function runAgentTurnWithFallback(params: {
if (!sanitized.trim()) return { skip: true };
return { text: sanitized, skip: false };
};
const handlePartialForTyping = async (
payload: ReplyPayload,
): Promise<string | undefined> => {
const handlePartialForTyping = async (payload: ReplyPayload): Promise<string | undefined> => {
const { text, skip } = normalizeStreamingText(payload);
if (skip || !text) return undefined;
await params.typingSignals.signalTextDelta(text);
@@ -174,10 +157,7 @@ export async function runAgentTurnWithFallback(params: {
startedAt,
},
});
const cliSessionId = getCliSessionId(
params.getActiveSessionEntry(),
provider,
);
const cliSessionId = getCliSessionId(params.getActiveSessionEntry(), provider);
return runCliAgent({
sessionId: params.followupRun.run.sessionId,
sessionKey: params.sessionKey,
@@ -227,8 +207,7 @@ export async function runAgentTurnWithFallback(params: {
return runEmbeddedPiAgent({
sessionId: params.followupRun.run.sessionId,
sessionKey: params.sessionKey,
messageProvider:
params.sessionCtx.Provider?.trim().toLowerCase() || undefined,
messageProvider: params.sessionCtx.Provider?.trim().toLowerCase() || undefined,
agentAccountId: params.sessionCtx.AccountId,
// Provider threading context for tool auto-injection
...buildThreadingToolContext({
@@ -244,10 +223,7 @@ export async function runAgentTurnWithFallback(params: {
prompt: params.commandBody,
extraSystemPrompt: params.followupRun.run.extraSystemPrompt,
ownerNumbers: params.followupRun.run.ownerNumbers,
enforceFinalTag: resolveEnforceFinalTag(
params.followupRun.run,
provider,
),
enforceFinalTag: resolveEnforceFinalTag(params.followupRun.run, provider),
provider,
model,
authProfileId,
@@ -264,9 +240,7 @@ export async function runAgentTurnWithFallback(params: {
params.sessionCtx.Provider,
);
if (!channel) return "markdown";
return isMarkdownCapableMessageChannel(channel)
? "markdown"
: "plain";
return isMarkdownCapableMessageChannel(channel) ? "markdown" : "plain";
})(),
bashElevated: params.followupRun.run.bashElevated,
timeoutMs: params.followupRun.run.timeoutMs,
@@ -276,11 +250,7 @@ export async function runAgentTurnWithFallback(params: {
onPartialReply: allowPartialStream
? async (payload) => {
const textForTyping = await handlePartialForTyping(payload);
if (
!params.opts?.onPartialReply ||
textForTyping === undefined
)
return;
if (!params.opts?.onPartialReply || textForTyping === undefined) return;
await params.opts.onPartialReply({
text: textForTyping,
mediaUrls: payload.mediaUrls,
@@ -291,8 +261,7 @@ export async function runAgentTurnWithFallback(params: {
await params.typingSignals.signalMessageStart();
},
onReasoningStream:
params.typingSignals.shouldStartOnReasoning ||
params.opts?.onReasoningStream
params.typingSignals.shouldStartOnReasoning || params.opts?.onReasoningStream
? async (payload) => {
await params.typingSignals.signalReasoningDelta();
await params.opts?.onReasoningStream?.({
@@ -305,16 +274,14 @@ export async function runAgentTurnWithFallback(params: {
// Trigger typing when tools start executing.
// Must await to ensure typing indicator starts before tool summaries are emitted.
if (evt.stream === "tool") {
const phase =
typeof evt.data.phase === "string" ? evt.data.phase : "";
const phase = typeof evt.data.phase === "string" ? evt.data.phase : "";
if (phase === "start" || phase === "update") {
await params.typingSignals.signalToolStart();
}
}
// Track auto-compaction completion
if (evt.stream === "compaction") {
const phase =
typeof evt.data.phase === "string" ? evt.data.phase : "";
const phase = typeof evt.data.phase === "string" ? evt.data.phase : "";
const willRetry = Boolean(evt.data.willRetry);
if (phase === "end" && !willRetry) {
autoCompactionCompleted = true;
@@ -338,22 +305,14 @@ export async function runAgentTurnWithFallback(params: {
params.sessionCtx.MessageSid,
);
// Let through payloads with audioAsVoice flag even if empty (need to track it)
if (
!isRenderablePayload(taggedPayload) &&
!payload.audioAsVoice
)
return;
const parsed = parseReplyDirectives(
taggedPayload.text ?? "",
{
currentMessageId: params.sessionCtx.MessageSid,
silentToken: SILENT_REPLY_TOKEN,
},
);
if (!isRenderablePayload(taggedPayload) && !payload.audioAsVoice) return;
const parsed = parseReplyDirectives(taggedPayload.text ?? "", {
currentMessageId: params.sessionCtx.MessageSid,
silentToken: SILENT_REPLY_TOKEN,
});
const cleaned = parsed.text || undefined;
const hasRenderableMedia =
Boolean(taggedPayload.mediaUrl) ||
(taggedPayload.mediaUrls?.length ?? 0) > 0;
Boolean(taggedPayload.mediaUrl) || (taggedPayload.mediaUrls?.length ?? 0) > 0;
// Skip empty payloads unless they have audioAsVoice flag (need to track it)
if (
!cleaned &&
@@ -367,35 +326,25 @@ export async function runAgentTurnWithFallback(params: {
const blockPayload: ReplyPayload = params.applyReplyToMode({
...taggedPayload,
text: cleaned,
audioAsVoice: Boolean(
parsed.audioAsVoice || payload.audioAsVoice,
),
audioAsVoice: Boolean(parsed.audioAsVoice || payload.audioAsVoice),
replyToId: taggedPayload.replyToId ?? parsed.replyToId,
replyToTag: taggedPayload.replyToTag || parsed.replyToTag,
replyToCurrent:
taggedPayload.replyToCurrent || parsed.replyToCurrent,
replyToCurrent: taggedPayload.replyToCurrent || parsed.replyToCurrent,
});
void params.typingSignals
.signalTextDelta(cleaned ?? taggedPayload.text)
.catch((err) => {
logVerbose(
`block reply typing signal failed: ${String(err)}`,
);
logVerbose(`block reply typing signal failed: ${String(err)}`);
});
// Use pipeline if available (block streaming enabled), otherwise send directly
if (
params.blockStreamingEnabled &&
params.blockReplyPipeline
) {
if (params.blockStreamingEnabled && params.blockReplyPipeline) {
params.blockReplyPipeline.enqueue(blockPayload);
} else {
// Send directly when flushing before tool execution (no streaming).
// Track sent key to avoid duplicate in final payloads.
directlySentBlockKeys.add(
createBlockReplyPayloadKey(blockPayload),
);
directlySentBlockKeys.add(createBlockReplyPayloadKey(blockPayload));
await params.opts?.onBlockReply?.(blockPayload);
}
}
@@ -456,9 +405,7 @@ export async function runAgentTurnWithFallback(params: {
};
}
if (embeddedError?.kind === "role_ordering") {
const didReset = await params.resetSessionAfterRoleOrderingConflict(
embeddedError.message,
);
const didReset = await params.resetSessionAfterRoleOrderingConflict(embeddedError.message);
if (didReset) {
return {
kind: "final",
@@ -476,10 +423,8 @@ export async function runAgentTurnWithFallback(params: {
isContextOverflowError(message) ||
/context.*overflow|too large|context window/i.test(message);
const isCompactionFailure = isCompactionFailureError(message);
const isSessionCorruption =
/function call turn comes immediately after/i.test(message);
const isRoleOrderingError =
/incorrect role information|roles must alternate/i.test(message);
const isSessionCorruption = /function call turn comes immediately after/i.test(message);
const isRoleOrderingError = /incorrect role information|roles must alternate/i.test(message);
if (
isCompactionFailure &&
@@ -495,8 +440,7 @@ export async function runAgentTurnWithFallback(params: {
};
}
if (isRoleOrderingError) {
const didReset =
await params.resetSessionAfterRoleOrderingConflict(message);
const didReset = await params.resetSessionAfterRoleOrderingConflict(message);
if (didReset) {
return {
kind: "final",
@@ -523,8 +467,7 @@ export async function runAgentTurnWithFallback(params: {
try {
// Delete transcript file if it exists
if (corruptedSessionId) {
const transcriptPath =
resolveSessionTranscriptPath(corruptedSessionId);
const transcriptPath = resolveSessionTranscriptPath(corruptedSessionId);
try {
fs.unlinkSync(transcriptPath);
} catch {
@@ -574,7 +517,6 @@ export async function runAgentTurnWithFallback(params: {
fallbackModel,
didLogHeartbeatStrip,
autoCompactionCompleted,
directlySentBlockKeys:
directlySentBlockKeys.size > 0 ? directlySentBlockKeys : undefined,
directlySentBlockKeys: directlySentBlockKeys.size > 0 ? directlySentBlockKeys : undefined,
};
}
@@ -196,8 +196,7 @@ describe("runReplyAgent typing (heartbeat)", () => {
durationMs: 1,
error: {
kind: "context_overflow",
message:
'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}',
message: 'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}',
},
},
}));
+41 -1
View File
@@ -7,6 +7,8 @@ import { scheduleGatewaySigusr1Restart, triggerClawdbotRestart } from "../../inf
import { parseActivationCommand } from "../group-activation.js";
import { parseSendPolicyCommand } from "../send-policy.js";
import { normalizeUsageDisplay, resolveResponseUsageMode } from "../thinking.js";
import { loadCostUsageSummary, loadSessionCostSummary } from "../../infra/session-cost-usage.js";
import { formatTokenCount, formatUsd } from "../../utils/usage-format.js";
import {
formatAbortReplyText,
isAbortTrigger,
@@ -141,10 +143,48 @@ export const handleUsageCommand: CommandHandler = async (params, allowTextComman
const rawArgs = normalized === "/usage" ? "" : normalized.slice("/usage".length).trim();
const requested = rawArgs ? normalizeUsageDisplay(rawArgs) : undefined;
if (rawArgs.toLowerCase().startsWith("cost")) {
const sessionSummary = await loadSessionCostSummary({
sessionId: params.sessionEntry?.sessionId,
sessionEntry: params.sessionEntry,
sessionFile: params.sessionEntry?.sessionFile,
config: params.cfg,
});
const summary = await loadCostUsageSummary({ days: 30, config: params.cfg });
const sessionCost = formatUsd(sessionSummary?.totalCost);
const sessionTokens = sessionSummary?.totalTokens
? formatTokenCount(sessionSummary.totalTokens)
: undefined;
const sessionMissing = sessionSummary?.missingCostEntries ?? 0;
const sessionSuffix = sessionMissing > 0 ? " (partial)" : "";
const sessionLine =
sessionCost || sessionTokens
? `Session ${sessionCost ?? "n/a"}${sessionSuffix}${sessionTokens ? ` · ${sessionTokens} tokens` : ""}`
: "Session n/a";
const todayKey = new Date().toLocaleDateString("en-CA");
const todayEntry = summary.daily.find((entry) => entry.date === todayKey);
const todayCost = formatUsd(todayEntry?.totalCost);
const todayMissing = todayEntry?.missingCostEntries ?? 0;
const todaySuffix = todayMissing > 0 ? " (partial)" : "";
const todayLine = `Today ${todayCost ?? "n/a"}${todaySuffix}`;
const last30Cost = formatUsd(summary.totals.totalCost);
const last30Missing = summary.totals.missingCostEntries;
const last30Suffix = last30Missing > 0 ? " (partial)" : "";
const last30Line = `Last 30d ${last30Cost ?? "n/a"}${last30Suffix}`;
return {
shouldContinue: false,
reply: { text: `💸 Usage cost\n${sessionLine}\n${todayLine}\n${last30Line}` },
};
}
if (rawArgs && !requested) {
return {
shouldContinue: false,
reply: { text: "⚙️ Usage: /usage off|tokens|full" },
reply: { text: "⚙️ Usage: /usage off|tokens|full|cost" },
};
}
+1 -1
View File
@@ -5,7 +5,7 @@ import path from "node:path";
import WebSocket from "ws";
import { ensurePortAvailable } from "../infra/ports.js";
import { createSubsystemLogger } from "../logging.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { CONFIG_DIR } from "../utils.js";
import { getHeadersWithAuth, normalizeCdpWsUrl } from "./cdp.js";
import { appendCdpPath } from "./cdp.helpers.js";
@@ -1,8 +1,7 @@
import { describe, it } from "vitest";
import { isTruthyEnvValue } from "../infra/env.js";
const LIVE =
isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.CLAWDBOT_LIVE_TEST);
const LIVE = isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.CLAWDBOT_LIVE_TEST);
const CDP_URL = process.env.CLAWDBOT_LIVE_BROWSER_CDP_URL?.trim() || "";
const describeLive = LIVE && CDP_URL ? describe : describe.skip;
+1 -1
View File
@@ -2,7 +2,7 @@ import type { Server } from "node:http";
import express from "express";
import { loadConfig } from "../config/config.js";
import { createSubsystemLogger } from "../logging.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { resolveBrowserConfig, resolveProfile, shouldStartLocalBrowserServer } from "./config.js";
import { ensureChromeExtensionRelayServer } from "./extension-relay.js";
import { registerBrowserRoutes } from "./routes/index.js";
+2 -4
View File
@@ -180,7 +180,7 @@ async function promptDiscordAllowFrom(params: {
}): Promise<ClawdbotConfig> {
const accountId =
params.accountId && normalizeAccountId(params.accountId)
? normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID
? (normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID)
: resolveDefaultDiscordAccountId(params.cfg);
const resolved = resolveDiscordAccount({ cfg: params.cfg, accountId });
const token = resolved.token;
@@ -249,9 +249,7 @@ async function promptDiscordAllowFrom(params: {
continue;
}
const ids = results.map((res) => res.id as string);
const unique = [
...new Set([...existing.map((v) => String(v).trim()).filter(Boolean), ...ids]),
];
const unique = [...new Set([...existing.map((v) => String(v).trim()).filter(Boolean), ...ids])];
return setDiscordAllowFrom(params.cfg, unique);
}
}
+1 -1
View File
@@ -80,7 +80,7 @@ async function promptIMessageAllowFrom(params: {
}): Promise<ClawdbotConfig> {
const accountId =
params.accountId && normalizeAccountId(params.accountId)
? normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID
? (normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID)
: resolveDefaultIMessageAccountId(params.cfg);
const resolved = resolveIMessageAccount({ cfg: params.cfg, accountId });
const existing = resolved.config.allowFrom ?? [];
+1 -1
View File
@@ -85,7 +85,7 @@ async function promptSignalAllowFrom(params: {
}): Promise<ClawdbotConfig> {
const accountId =
params.accountId && normalizeAccountId(params.accountId)
? normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID
? (normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID)
: resolveDefaultSignalAccountId(params.cfg);
const resolved = resolveSignalAccount({ cfg: params.cfg, accountId });
const existing = resolved.config.allowFrom ?? [];
+2 -4
View File
@@ -232,7 +232,7 @@ async function promptSlackAllowFrom(params: {
}): Promise<ClawdbotConfig> {
const accountId =
params.accountId && normalizeAccountId(params.accountId)
? normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID
? (normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID)
: resolveDefaultSlackAccountId(params.cfg);
const resolved = resolveSlackAccount({ cfg: params.cfg, accountId });
const token = resolved.config.userToken ?? resolved.config.botToken ?? "";
@@ -299,9 +299,7 @@ async function promptSlackAllowFrom(params: {
continue;
}
const ids = results.map((res) => res.id as string);
const unique = [
...new Set([...existing.map((v) => String(v).trim()).filter(Boolean), ...ids]),
];
const unique = [...new Set([...existing.map((v) => String(v).trim()).filter(Boolean), ...ids])];
return setSlackAllowFrom(params.cfg, unique);
}
}
+5 -4
View File
@@ -80,9 +80,10 @@ async function promptTelegramAllowFrom(params: {
const username = stripped.startsWith("@") ? stripped : `@${stripped}`;
const url = `https://api.telegram.org/bot${token}/getChat?chat_id=${encodeURIComponent(username)}`;
const res = await fetch(url);
const data = (await res.json().catch(() => null)) as
| { ok?: boolean; result?: { id?: number | string } }
| null;
const data = (await res.json().catch(() => null)) as {
ok?: boolean;
result?: { id?: number | string };
} | null;
const id = data?.ok ? data?.result?.id : undefined;
if (typeof id === "number" || typeof id === "string") return String(id);
return null;
@@ -164,7 +165,7 @@ async function promptTelegramAllowFromForAccount(params: {
}): Promise<ClawdbotConfig> {
const accountId =
params.accountId && normalizeAccountId(params.accountId)
? normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID
? (normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID)
: resolveDefaultTelegramAccountId(params.cfg);
return promptTelegramAllowFrom({
cfg: params.cfg,
+43 -10
View File
@@ -5,8 +5,12 @@ import {
getFlagValue,
getCommandPath,
getPrimaryCommand,
getPositiveIntFlagValue,
getVerboseFlag,
hasHelpOrVersion,
hasFlag,
shouldMigrateState,
shouldMigrateStateFromPath,
} from "./argv.js";
describe("argv helpers", () => {
@@ -17,16 +21,9 @@ describe("argv helpers", () => {
});
it("extracts command path ignoring flags and terminator", () => {
expect(getCommandPath(["node", "clawdbot", "status", "--json"], 2)).toEqual([
"status",
]);
expect(getCommandPath(["node", "clawdbot", "agents", "list"], 2)).toEqual([
"agents",
"list",
]);
expect(getCommandPath(["node", "clawdbot", "status", "--", "ignored"], 2)).toEqual([
"status",
]);
expect(getCommandPath(["node", "clawdbot", "status", "--json"], 2)).toEqual(["status"]);
expect(getCommandPath(["node", "clawdbot", "agents", "list"], 2)).toEqual(["agents", "list"]);
expect(getCommandPath(["node", "clawdbot", "status", "--", "ignored"], 2)).toEqual(["status"]);
});
it("returns primary command", () => {
@@ -53,6 +50,27 @@ describe("argv helpers", () => {
expect(getFlagValue(["node", "clawdbot", "--", "--timeout=99"], "--timeout")).toBeUndefined();
});
it("parses verbose flags", () => {
expect(getVerboseFlag(["node", "clawdbot", "status", "--verbose"])).toBe(true);
expect(getVerboseFlag(["node", "clawdbot", "status", "--debug"])).toBe(false);
expect(getVerboseFlag(["node", "clawdbot", "status", "--debug"], { includeDebug: true })).toBe(
true,
);
});
it("parses positive integer flag values", () => {
expect(getPositiveIntFlagValue(["node", "clawdbot", "status"], "--timeout")).toBeUndefined();
expect(
getPositiveIntFlagValue(["node", "clawdbot", "status", "--timeout"], "--timeout"),
).toBeNull();
expect(
getPositiveIntFlagValue(["node", "clawdbot", "status", "--timeout", "5000"], "--timeout"),
).toBe(5000);
expect(
getPositiveIntFlagValue(["node", "clawdbot", "status", "--timeout", "nope"], "--timeout"),
).toBeUndefined();
});
it("builds parse argv from raw args", () => {
const nodeArgv = buildParseArgv({
programName: "clawdbot",
@@ -80,4 +98,19 @@ describe("argv helpers", () => {
});
expect(fallbackArgv).toEqual(["node", "clawdbot", "status"]);
});
it("decides when to migrate state", () => {
expect(shouldMigrateState(["node", "clawdbot", "status"])).toBe(false);
expect(shouldMigrateState(["node", "clawdbot", "health"])).toBe(false);
expect(shouldMigrateState(["node", "clawdbot", "sessions"])).toBe(false);
expect(shouldMigrateState(["node", "clawdbot", "memory", "status"])).toBe(false);
expect(shouldMigrateState(["node", "clawdbot", "agent", "--message", "hi"])).toBe(false);
expect(shouldMigrateState(["node", "clawdbot", "agents", "list"])).toBe(true);
expect(shouldMigrateState(["node", "clawdbot", "message", "send"])).toBe(true);
});
it("reuses command path for migrate state decisions", () => {
expect(shouldMigrateStateFromPath(["status"])).toBe(false);
expect(shouldMigrateStateFromPath(["agents", "list"])).toBe(true);
});
});
+28 -7
View File
@@ -13,6 +13,12 @@ function isValueToken(arg: string | undefined): boolean {
return /^-\d+(?:\.\d+)?$/.test(arg);
}
function parsePositiveInt(value: string): number | undefined {
const parsed = Number.parseInt(value, 10);
if (Number.isNaN(parsed) || parsed <= 0) return undefined;
return parsed;
}
export function hasFlag(argv: string[], name: string): boolean {
const args = argv.slice(2);
for (const arg of args) {
@@ -39,6 +45,18 @@ export function getFlagValue(argv: string[], name: string): string | null | unde
return undefined;
}
export function getVerboseFlag(argv: string[], options?: { includeDebug?: boolean }): boolean {
if (hasFlag(argv, "--verbose")) return true;
if (options?.includeDebug && hasFlag(argv, "--debug")) return true;
return false;
}
export function getPositiveIntFlagValue(argv: string[], name: string): number | null | undefined {
const raw = getFlagValue(argv, name);
if (raw === null || raw === undefined) return raw;
return parsePositiveInt(raw);
}
export function getCommandPath(argv: string[], depth = 2): string[] {
const args = argv.slice(2);
const path: string[] = [];
@@ -83,12 +101,15 @@ export function buildParseArgv(params: {
return ["node", programName || "clawdbot", ...normalizedArgv];
}
export function isReadOnlyCommand(argv: string[]): boolean {
const path = getCommandPath(argv, 2);
if (path.length === 0) return false;
export function shouldMigrateStateFromPath(path: string[]): boolean {
if (path.length === 0) return true;
const [primary, secondary] = path;
if (primary === "health" || primary === "status" || primary === "sessions") return true;
if (primary === "memory" && secondary === "status") return true;
if (primary === "agents" && secondary === "list") return true;
return false;
if (primary === "health" || primary === "status" || primary === "sessions") return false;
if (primary === "memory" && secondary === "status") return false;
if (primary === "agent") return false;
return true;
}
export function shouldMigrateState(argv: string[]): boolean {
return shouldMigrateStateFromPath(getCommandPath(argv, 2));
}
+14 -15
View File
@@ -8,6 +8,14 @@ import {
import { danger } from "../globals.js";
import { defaultRuntime } from "../runtime.js";
import type { BrowserParentOpts } from "./browser-cli-shared.js";
import { runCommandWithRuntime } from "./cli-utils.js";
function runBrowserObserve(action: () => Promise<void>) {
return runCommandWithRuntime(defaultRuntime, action, (err) => {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
});
}
export function registerBrowserActionObserveCommands(
browser: Command,
@@ -22,7 +30,7 @@ export function registerBrowserActionObserveCommands(
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
try {
await runBrowserObserve(async () => {
const result = await browserConsoleMessages(baseUrl, {
level: opts.level?.trim() || undefined,
targetId: opts.targetId?.trim() || undefined,
@@ -33,10 +41,7 @@ export function registerBrowserActionObserveCommands(
return;
}
defaultRuntime.log(JSON.stringify(result.messages, null, 2));
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
});
browser
@@ -47,7 +52,7 @@ export function registerBrowserActionObserveCommands(
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
try {
await runBrowserObserve(async () => {
const result = await browserPdfSave(baseUrl, {
targetId: opts.targetId?.trim() || undefined,
profile,
@@ -57,10 +62,7 @@ export function registerBrowserActionObserveCommands(
return;
}
defaultRuntime.log(`PDF: ${result.path}`);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
});
browser
@@ -80,7 +82,7 @@ export function registerBrowserActionObserveCommands(
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
try {
await runBrowserObserve(async () => {
const result = await browserResponseBody(baseUrl, {
url,
targetId: opts.targetId?.trim() || undefined,
@@ -93,9 +95,6 @@ export function registerBrowserActionObserveCommands(
return;
}
defaultRuntime.log(result.response.body);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
});
}
+18 -25
View File
@@ -11,6 +11,14 @@ import {
import { danger } from "../globals.js";
import { defaultRuntime } from "../runtime.js";
import type { BrowserParentOpts } from "./browser-cli-shared.js";
import { runCommandWithRuntime } from "./cli-utils.js";
function runBrowserDebug(action: () => Promise<void>) {
return runCommandWithRuntime(defaultRuntime, action, (err) => {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
});
}
export function registerBrowserDebugCommands(
browser: Command,
@@ -25,7 +33,7 @@ export function registerBrowserDebugCommands(
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
try {
await runBrowserDebug(async () => {
const result = await browserHighlight(baseUrl, {
ref: ref.trim(),
targetId: opts.targetId?.trim() || undefined,
@@ -36,10 +44,7 @@ export function registerBrowserDebugCommands(
return;
}
defaultRuntime.log(`highlighted ${ref.trim()}`);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
});
browser
@@ -51,7 +56,7 @@ export function registerBrowserDebugCommands(
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
try {
await runBrowserDebug(async () => {
const result = await browserPageErrors(baseUrl, {
targetId: opts.targetId?.trim() || undefined,
clear: Boolean(opts.clear),
@@ -70,10 +75,7 @@ export function registerBrowserDebugCommands(
.map((e) => `${e.timestamp} ${e.name ? `${e.name}: ` : ""}${e.message}`)
.join("\n"),
);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
});
browser
@@ -86,7 +88,7 @@ export function registerBrowserDebugCommands(
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
try {
await runBrowserDebug(async () => {
const result = await browserRequests(baseUrl, {
targetId: opts.targetId?.trim() || undefined,
filter: opts.filter?.trim() || undefined,
@@ -111,10 +113,7 @@ export function registerBrowserDebugCommands(
})
.join("\n"),
);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
});
const trace = browser.command("trace").description("Record a Playwright trace");
@@ -130,7 +129,7 @@ export function registerBrowserDebugCommands(
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
try {
await runBrowserDebug(async () => {
const result = await browserTraceStart(baseUrl, {
targetId: opts.targetId?.trim() || undefined,
screenshots: Boolean(opts.screenshots),
@@ -143,10 +142,7 @@ export function registerBrowserDebugCommands(
return;
}
defaultRuntime.log("trace started");
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
});
trace
@@ -158,7 +154,7 @@ export function registerBrowserDebugCommands(
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
try {
await runBrowserDebug(async () => {
const result = await browserTraceStop(baseUrl, {
targetId: opts.targetId?.trim() || undefined,
path: opts.out?.trim() || undefined,
@@ -169,9 +165,6 @@ export function registerBrowserDebugCommands(
return;
}
defaultRuntime.log(`TRACE:${result.path}`);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
});
}
+38 -75
View File
@@ -19,6 +19,14 @@ import { browserAct } from "../browser/client-actions-core.js";
import { danger, info } from "../globals.js";
import { defaultRuntime } from "../runtime.js";
import type { BrowserParentOpts } from "./browser-cli-shared.js";
import { runCommandWithRuntime } from "./cli-utils.js";
function runBrowserCommand(action: () => Promise<void>) {
return runCommandWithRuntime(defaultRuntime, action, (err) => {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
});
}
export function registerBrowserManageCommands(
browser: Command,
@@ -30,7 +38,7 @@ export function registerBrowserManageCommands(
.action(async (_opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
try {
await runBrowserCommand(async () => {
const status = await browserStatus(baseUrl, {
profile: parent?.browserProfile,
});
@@ -53,10 +61,7 @@ export function registerBrowserManageCommands(
...(status.detectError ? [`detectError: ${status.detectError}`] : []),
].join("\n"),
);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
});
browser
@@ -66,7 +71,7 @@ export function registerBrowserManageCommands(
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
try {
await runBrowserCommand(async () => {
await browserStart(baseUrl, { profile });
const status = await browserStatus(baseUrl, { profile });
if (parent?.json) {
@@ -75,10 +80,7 @@ export function registerBrowserManageCommands(
}
const name = status.profile ?? "clawd";
defaultRuntime.log(info(`🦞 browser [${name}] running: ${status.running}`));
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
});
browser
@@ -88,7 +90,7 @@ export function registerBrowserManageCommands(
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
try {
await runBrowserCommand(async () => {
await browserStop(baseUrl, { profile });
const status = await browserStatus(baseUrl, { profile });
if (parent?.json) {
@@ -97,10 +99,7 @@ export function registerBrowserManageCommands(
}
const name = status.profile ?? "clawd";
defaultRuntime.log(info(`🦞 browser [${name}] running: ${status.running}`));
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
});
browser
@@ -110,7 +109,7 @@ export function registerBrowserManageCommands(
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
try {
await runBrowserCommand(async () => {
const result = await browserResetProfile(baseUrl, { profile });
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
@@ -122,10 +121,7 @@ export function registerBrowserManageCommands(
}
const dest = result.to ?? result.from;
defaultRuntime.log(info(`🦞 browser profile moved to Trash (${dest})`));
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
});
browser
@@ -135,7 +131,7 @@ export function registerBrowserManageCommands(
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
try {
await runBrowserCommand(async () => {
const tabs = await browserTabs(baseUrl, { profile });
if (parent?.json) {
defaultRuntime.log(JSON.stringify({ tabs }, null, 2));
@@ -152,10 +148,7 @@ export function registerBrowserManageCommands(
)
.join("\n"),
);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
});
const tab = browser
@@ -165,7 +158,7 @@ export function registerBrowserManageCommands(
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
try {
await runBrowserCommand(async () => {
const result = (await browserTabAction(baseUrl, {
action: "list",
profile,
@@ -186,10 +179,7 @@ export function registerBrowserManageCommands(
)
.join("\n"),
);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
});
tab
@@ -199,7 +189,7 @@ export function registerBrowserManageCommands(
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
try {
await runBrowserCommand(async () => {
const result = await browserTabAction(baseUrl, {
action: "new",
profile,
@@ -209,10 +199,7 @@ export function registerBrowserManageCommands(
return;
}
defaultRuntime.log("opened new tab");
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
});
tab
@@ -228,7 +215,7 @@ export function registerBrowserManageCommands(
defaultRuntime.exit(1);
return;
}
try {
await runBrowserCommand(async () => {
const result = await browserTabAction(baseUrl, {
action: "select",
index: Math.floor(index) - 1,
@@ -239,10 +226,7 @@ export function registerBrowserManageCommands(
return;
}
defaultRuntime.log(`selected tab ${Math.floor(index)}`);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
});
tab
@@ -260,7 +244,7 @@ export function registerBrowserManageCommands(
defaultRuntime.exit(1);
return;
}
try {
await runBrowserCommand(async () => {
const result = await browserTabAction(baseUrl, {
action: "close",
index: idx,
@@ -271,10 +255,7 @@ export function registerBrowserManageCommands(
return;
}
defaultRuntime.log("closed tab");
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
});
browser
@@ -285,17 +266,14 @@ export function registerBrowserManageCommands(
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
try {
await runBrowserCommand(async () => {
const tab = await browserOpenTab(baseUrl, url, { profile });
if (parent?.json) {
defaultRuntime.log(JSON.stringify(tab, null, 2));
return;
}
defaultRuntime.log(`opened: ${tab.url}\nid: ${tab.targetId}`);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
});
browser
@@ -306,17 +284,14 @@ export function registerBrowserManageCommands(
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
try {
await runBrowserCommand(async () => {
await browserFocusTab(baseUrl, targetId, { profile });
if (parent?.json) {
defaultRuntime.log(JSON.stringify({ ok: true }, null, 2));
return;
}
defaultRuntime.log(`focused tab ${targetId}`);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
});
browser
@@ -327,7 +302,7 @@ export function registerBrowserManageCommands(
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
try {
await runBrowserCommand(async () => {
if (targetId?.trim()) {
await browserCloseTab(baseUrl, targetId.trim(), { profile });
} else {
@@ -338,10 +313,7 @@ export function registerBrowserManageCommands(
return;
}
defaultRuntime.log("closed tab");
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
});
// Profile management commands
@@ -351,7 +323,7 @@ export function registerBrowserManageCommands(
.action(async (_opts, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
try {
await runBrowserCommand(async () => {
const profiles = await browserProfiles(baseUrl);
if (parent?.json) {
defaultRuntime.log(JSON.stringify({ profiles }, null, 2));
@@ -373,10 +345,7 @@ export function registerBrowserManageCommands(
})
.join("\n"),
);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
});
browser
@@ -390,7 +359,7 @@ export function registerBrowserManageCommands(
async (opts: { name: string; color?: string; cdpUrl?: string; driver?: string }, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
try {
await runBrowserCommand(async () => {
const result = await browserCreateProfile(baseUrl, {
name: opts.name,
color: opts.color,
@@ -409,10 +378,7 @@ export function registerBrowserManageCommands(
}`,
),
);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
},
);
@@ -423,7 +389,7 @@ export function registerBrowserManageCommands(
.action(async (opts: { name: string }, cmd) => {
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
try {
await runBrowserCommand(async () => {
const result = await browserDeleteProfile(baseUrl, opts.name);
if (parent?.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
@@ -433,9 +399,6 @@ export function registerBrowserManageCommands(
? `🦞 Deleted profile "${result.profile}" (user data removed)`
: `🦞 Deleted profile "${result.profile}" (no user data found)`;
defaultRuntime.log(info(msg));
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
});
}
+26 -45
View File
@@ -17,12 +17,20 @@ import { defaultRuntime } from "../runtime.js";
import { parseBooleanValue } from "../utils/boolean.js";
import type { BrowserParentOpts } from "./browser-cli-shared.js";
import { registerBrowserCookiesAndStorageCommands } from "./browser-cli-state.cookies-storage.js";
import { runCommandWithRuntime } from "./cli-utils.js";
function parseOnOff(raw: string): boolean | null {
const parsed = parseBooleanValue(raw);
return parsed === undefined ? null : parsed;
}
function runBrowserCommand(action: () => Promise<void>) {
return runCommandWithRuntime(defaultRuntime, action, (err) => {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
});
}
export function registerBrowserStateCommands(
browser: Command,
parentOpts: (cmd: Command) => BrowserParentOpts,
@@ -46,7 +54,7 @@ export function registerBrowserStateCommands(
defaultRuntime.exit(1);
return;
}
try {
await runBrowserCommand(async () => {
const result = await browserAct(
baseUrl,
{
@@ -62,10 +70,7 @@ export function registerBrowserStateCommands(
return;
}
defaultRuntime.log(`viewport set: ${width}x${height}`);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
});
set
@@ -83,7 +88,7 @@ export function registerBrowserStateCommands(
defaultRuntime.exit(1);
return;
}
try {
await runBrowserCommand(async () => {
const result = await browserSetOffline(baseUrl, {
offline,
targetId: opts.targetId?.trim() || undefined,
@@ -94,10 +99,7 @@ export function registerBrowserStateCommands(
return;
}
defaultRuntime.log(`offline: ${offline}`);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
});
set
@@ -109,7 +111,7 @@ export function registerBrowserStateCommands(
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
try {
await runBrowserCommand(async () => {
const parsed = JSON.parse(String(opts.json)) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("headers json must be an object");
@@ -128,10 +130,7 @@ export function registerBrowserStateCommands(
return;
}
defaultRuntime.log("headers set");
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
});
set
@@ -145,7 +144,7 @@ export function registerBrowserStateCommands(
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
try {
await runBrowserCommand(async () => {
const result = await browserSetHttpCredentials(baseUrl, {
username: username?.trim() || undefined,
password,
@@ -158,10 +157,7 @@ export function registerBrowserStateCommands(
return;
}
defaultRuntime.log(opts.clear ? "credentials cleared" : "credentials set");
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
});
set
@@ -177,7 +173,7 @@ export function registerBrowserStateCommands(
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
try {
await runBrowserCommand(async () => {
const result = await browserSetGeolocation(baseUrl, {
latitude: Number.isFinite(latitude) ? latitude : undefined,
longitude: Number.isFinite(longitude) ? longitude : undefined,
@@ -192,10 +188,7 @@ export function registerBrowserStateCommands(
return;
}
defaultRuntime.log(opts.clear ? "geolocation cleared" : "geolocation set");
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
});
set
@@ -215,7 +208,7 @@ export function registerBrowserStateCommands(
defaultRuntime.exit(1);
return;
}
try {
await runBrowserCommand(async () => {
const result = await browserSetMedia(baseUrl, {
colorScheme,
targetId: opts.targetId?.trim() || undefined,
@@ -226,10 +219,7 @@ export function registerBrowserStateCommands(
return;
}
defaultRuntime.log(`media colorScheme: ${colorScheme}`);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
});
set
@@ -241,7 +231,7 @@ export function registerBrowserStateCommands(
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
try {
await runBrowserCommand(async () => {
const result = await browserSetTimezone(baseUrl, {
timezoneId,
targetId: opts.targetId?.trim() || undefined,
@@ -252,10 +242,7 @@ export function registerBrowserStateCommands(
return;
}
defaultRuntime.log(`timezone: ${timezoneId}`);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
});
set
@@ -267,7 +254,7 @@ export function registerBrowserStateCommands(
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
try {
await runBrowserCommand(async () => {
const result = await browserSetLocale(baseUrl, {
locale,
targetId: opts.targetId?.trim() || undefined,
@@ -278,10 +265,7 @@ export function registerBrowserStateCommands(
return;
}
defaultRuntime.log(`locale: ${locale}`);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
});
set
@@ -293,7 +277,7 @@ export function registerBrowserStateCommands(
const parent = parentOpts(cmd);
const baseUrl = resolveBrowserControlUrl(parent?.url);
const profile = parent?.browserProfile;
try {
await runBrowserCommand(async () => {
const result = await browserSetDevice(baseUrl, {
name,
targetId: opts.targetId?.trim() || undefined,
@@ -304,9 +288,6 @@ export function registerBrowserStateCommands(
return;
}
defaultRuntime.log(`device: ${name}`);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
});
});
}
+30 -45
View File
@@ -14,6 +14,7 @@ import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js";
import { runChannelLogin, runChannelLogout } from "./channel-auth.js";
import { runCommandWithRuntime } from "./cli-utils.js";
import { hasExplicitOptions } from "./command-options.js";
const optionNamesAdd = [
@@ -44,6 +45,17 @@ const optionNamesAdd = [
const optionNamesRemove = ["channel", "account", "delete"] as const;
function runChannelsCommand(action: () => Promise<void>) {
return runCommandWithRuntime(defaultRuntime, action);
}
function runChannelsCommandWithDanger(action: () => Promise<void>, label: string) {
return runCommandWithRuntime(defaultRuntime, action, (err) => {
defaultRuntime.error(danger(`${label}: ${String(err)}`));
defaultRuntime.exit(1);
});
}
export function registerChannelsCli(program: Command) {
const channelNames = listChannelPlugins()
.map((plugin) => plugin.id)
@@ -66,12 +78,9 @@ export function registerChannelsCli(program: Command) {
.option("--no-usage", "Skip model provider usage/quota snapshots")
.option("--json", "Output JSON", false)
.action(async (opts) => {
try {
await runChannelsCommand(async () => {
await channelsListCommand(opts, defaultRuntime);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
});
channels
@@ -81,12 +90,9 @@ export function registerChannelsCli(program: Command) {
.option("--timeout <ms>", "Timeout in ms", "10000")
.option("--json", "Output JSON", false)
.action(async (opts) => {
try {
await runChannelsCommand(async () => {
await channelsStatusCommand(opts, defaultRuntime);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
});
channels
@@ -98,12 +104,9 @@ export function registerChannelsCli(program: Command) {
.option("--timeout <ms>", "Timeout in ms", "10000")
.option("--json", "Output JSON", false)
.action(async (opts) => {
try {
await runChannelsCommand(async () => {
await channelsCapabilitiesCommand(opts, defaultRuntime);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
});
channels
@@ -115,7 +118,7 @@ export function registerChannelsCli(program: Command) {
.option("--kind <kind>", "Target kind (auto|user|group)", "auto")
.option("--json", "Output JSON", false)
.action(async (entries, opts) => {
try {
await runChannelsCommand(async () => {
await channelsResolveCommand(
{
channel: opts.channel as string | undefined,
@@ -126,10 +129,7 @@ export function registerChannelsCli(program: Command) {
},
defaultRuntime,
);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
});
channels
@@ -139,12 +139,9 @@ export function registerChannelsCli(program: Command) {
.option("--lines <n>", "Number of lines (default: 200)", "200")
.option("--json", "Output JSON", false)
.action(async (opts) => {
try {
await runChannelsCommand(async () => {
await channelsLogsCommand(opts, defaultRuntime);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
});
channels
@@ -174,13 +171,10 @@ export function registerChannelsCli(program: Command) {
.option("--initial-sync-limit <n>", "Matrix initial sync limit")
.option("--use-env", "Use env token (default account only)", false)
.action(async (opts, command) => {
try {
await runChannelsCommand(async () => {
const hasFlags = hasExplicitOptions(command, optionNamesAdd);
await channelsAddCommand(opts, defaultRuntime, { hasFlags });
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
});
channels
@@ -190,13 +184,10 @@ export function registerChannelsCli(program: Command) {
.option("--account <id>", "Account id (default when omitted)")
.option("--delete", "Delete config entries (no prompt)", false)
.action(async (opts, command) => {
try {
await runChannelsCommand(async () => {
const hasFlags = hasExplicitOptions(command, optionNamesRemove);
await channelsRemoveCommand(opts, defaultRuntime, { hasFlags });
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
});
channels
@@ -206,7 +197,7 @@ export function registerChannelsCli(program: Command) {
.option("--account <id>", "Account id (accountId)")
.option("--verbose", "Verbose connection logs", false)
.action(async (opts) => {
try {
await runChannelsCommandWithDanger(async () => {
await runChannelLogin(
{
channel: opts.channel as string | undefined,
@@ -215,10 +206,7 @@ export function registerChannelsCli(program: Command) {
},
defaultRuntime,
);
} catch (err) {
defaultRuntime.error(danger(`Channel login failed: ${String(err)}`));
defaultRuntime.exit(1);
}
}, "Channel login failed");
});
channels
@@ -227,7 +215,7 @@ export function registerChannelsCli(program: Command) {
.option("--channel <channel>", "Channel alias (default: whatsapp)")
.option("--account <id>", "Account id (accountId)")
.action(async (opts) => {
try {
await runChannelsCommandWithDanger(async () => {
await runChannelLogout(
{
channel: opts.channel as string | undefined,
@@ -235,9 +223,6 @@ export function registerChannelsCli(program: Command) {
},
defaultRuntime,
);
} catch (err) {
defaultRuntime.error(danger(`Channel logout failed: ${String(err)}`));
defaultRuntime.exit(1);
}
}, "Channel logout failed");
});
}
+17
View File
@@ -29,3 +29,20 @@ export async function withManager<T>(params: {
}
}
}
export async function runCommandWithRuntime(
runtime: { error: (message: string) => void; exit: (code: number) => void },
action: () => Promise<void>,
onError?: (error: unknown) => void,
): Promise<void> {
try {
await action();
} catch (err) {
if (onError) {
onError(err);
return;
}
runtime.error(String(err));
runtime.exit(1);
}
}
+3 -5
View File
@@ -4,6 +4,7 @@ import { docsSearchCommand } from "../commands/docs.js";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js";
import { runCommandWithRuntime } from "./cli-utils.js";
export function registerDocsCli(program: Command) {
program
@@ -15,11 +16,8 @@ export function registerDocsCli(program: Command) {
() => `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/docs", "docs.clawd.bot/cli/docs")}\n`,
)
.action(async (queryParts: string[]) => {
try {
await runCommandWithRuntime(defaultRuntime, async () => {
await docsSearchCommand(queryParts, defaultRuntime);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
});
}
+17 -20
View File
@@ -7,6 +7,7 @@ import { defaultRuntime } from "../../runtime.js";
import { formatDocsLink } from "../../terminal/links.js";
import { colorize, isRich, theme } from "../../terminal/theme.js";
import { withProgress } from "../progress.js";
import { runCommandWithRuntime } from "../cli-utils.js";
import { callGatewayCli, gatewayCallOpts } from "./call.js";
import type { GatewayDiscoverOpts } from "./discover.js";
import {
@@ -41,6 +42,14 @@ function styleHealthChannelLine(line: string, rich: boolean): string {
return line;
}
function runGatewayCommand(action: () => Promise<void>, label?: string) {
return runCommandWithRuntime(defaultRuntime, action, (err) => {
const message = String(err);
defaultRuntime.error(label ? `${label}: ${message}` : message);
defaultRuntime.exit(1);
});
}
export function registerGatewayCli(program: Command) {
const gateway = addGatewayRunCommand(
program
@@ -67,7 +76,7 @@ export function registerGatewayCli(program: Command) {
.argument("<method>", "Method name (health/status/system-presence/cron.*)")
.option("--params <json>", "JSON object string for params", "{}")
.action(async (method, opts) => {
try {
await runGatewayCommand(async () => {
const params = JSON.parse(String(opts.params ?? "{}"));
const result = await callGatewayCli(method, opts, params);
if (opts.json) {
@@ -79,10 +88,7 @@ export function registerGatewayCli(program: Command) {
`${colorize(rich, theme.heading, "Gateway call")}: ${colorize(rich, theme.muted, String(method))}`,
);
defaultRuntime.log(JSON.stringify(result, null, 2));
} catch (err) {
defaultRuntime.error(`Gateway call failed: ${String(err)}`);
defaultRuntime.exit(1);
}
}, "Gateway call failed");
}),
);
@@ -91,7 +97,7 @@ export function registerGatewayCli(program: Command) {
.command("health")
.description("Fetch Gateway health")
.action(async (opts) => {
try {
await runGatewayCommand(async () => {
const result = await callGatewayCli("health", opts);
if (opts.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
@@ -110,10 +116,7 @@ export function registerGatewayCli(program: Command) {
defaultRuntime.log(styleHealthChannelLine(line, rich));
}
}
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
}),
);
@@ -129,12 +132,9 @@ export function registerGatewayCli(program: Command) {
.option("--timeout <ms>", "Overall probe budget in ms", "3000")
.option("--json", "Output JSON", false)
.action(async (opts) => {
try {
await runGatewayCommand(async () => {
await gatewayStatusCommand(opts, defaultRuntime);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
});
gateway
@@ -145,7 +145,7 @@ export function registerGatewayCli(program: Command) {
.option("--timeout <ms>", "Per-command timeout in ms", "2000")
.option("--json", "Output JSON", false)
.action(async (opts: GatewayDiscoverOpts) => {
try {
await runGatewayCommand(async () => {
const timeoutMs = parseDiscoverTimeoutMs(opts.timeout, 2000);
const beacons = await withProgress(
{
@@ -200,9 +200,6 @@ export function registerGatewayCli(program: Command) {
defaultRuntime.log(line);
}
}
} catch (err) {
defaultRuntime.error(`gateway discover failed: ${String(err)}`);
defaultRuntime.exit(1);
}
}, "gateway discover failed");
});
}
Loaded 100 of 231 files, more files were not shown because too many files have changed in this diff. Show more